diff --git a/.github/workflows/pest.yml b/.github/workflows/pest.yml new file mode 100644 index 0000000..6e87473 --- /dev/null +++ b/.github/workflows/pest.yml @@ -0,0 +1,33 @@ +name: Pest Tests + +on: + pull_request: + branches: + - main + - master + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: [8.3] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pdo, sqlite, pdo_sqlite, bcmath, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run Pest tests + run: vendor/bin/pest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31de4df --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +composer.lock +/node_modules +package-lock.json +/vendor +vite.hot +.DS_* \ No newline at end of file diff --git a/README.md b/README.md index 8b64f4a..4c9fd80 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,46 @@ On this page you can manage the query strings you wish to strip, simply : You'll see your query string in the list. +### Database Redirects + +By default, the addon stores redirects and query strings as YAML files in `content/alt-redirect/`. For containerised deployments or larger sites, you may wish to store these in your database instead. + +#### 1. Publish Configuration +If you haven't already, publish the configuration file to your site: + +```bash +php artisan vendor:publish --tag=alt-redirect-config +``` + +#### 2. Run Migrations +While migrations are loaded automatically, you can publish them if you wish to customize them: + +```bash +php artisan vendor:publish --tag=alt-redirect-migrations +``` + +Run the migrations to create the necessary tables: + +```bash +php artisan migrate +``` + +#### 3. Switch Driver +In your `config/alt-redirect.php`, change the `driver` from `file` to `database`: + +```php +'driver' => 'database', +``` + +#### 4. Migrate Existing Data (Optional) +If you already have file-based redirects and want to move them to your database, you can use the following Artisan command: + +```bash +php artisan alt-redirect:migrate-file-redirects +``` + +The command will check if the required tables exist and then migrate all your redirects and query strings. + #### Artisan Command We have provided an Artisan command to force the creation of defaults. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..94b97b9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,30 @@ +# Reporting a Vulnerability + +We take security very seriously at Alt Design. If you discover a potential security vulnerability in this project, please report it **privately** to us rather than opening a public issue. + +### Contact + +Please send all security reports to: + +**Email:** ben@alt-design.net + +Include as much detail as possible, such as: + +- Steps to reproduce the issue +- Impact assessment +- Affected version(s) +- Any suggested mitigations (if known) + +We will acknowledge receipt of your report promptly and keep you updated on progress. + +### Response Time + +We aim to respond within 48 hours. Critical vulnerabilities may receive priority handling. + +### Public Disclosure + +After a fix has been released and verified, we may publicly disclose the vulnerability in a security advisory. We will give credit to reporters unless they request anonymity. + +### Security Updates + +We publish security updates as new releases. We strongly recommend users upgrade to the latest version to stay protected. diff --git a/composer.json b/composer.json index b56c445..4baa9eb 100644 --- a/composer.json +++ b/composer.json @@ -7,9 +7,14 @@ "AltDesign\\AltRedirect\\": "src" } }, + "autoload-dev": { + "psr-4": { + "AltDesign\\AltRedirect\\Tests\\": "tests" + } + }, "require": { "php": "^8.1", - "statamic/cms": "^4.0|^5.17.0" + "statamic/cms": "^6.0" }, "extra": { "statamic": { @@ -24,7 +29,14 @@ }, "config": { "allow-plugins": { - "pixelfear/composer-dist-plugin": false + "pixelfear/composer-dist-plugin": true, + "pestphp/pest-plugin": true } + }, + "require-dev": { + "laravel/pint": "^1.29", + "pestphp/pest": "^4.4", + "pestphp/pest-plugin-laravel": "^4.1", + "orchestra/testbench": "^11.1" } } diff --git a/config/alt-redirect.php b/config/alt-redirect.php index 6136d21..fb13ed6 100644 --- a/config/alt-redirect.php +++ b/config/alt-redirect.php @@ -16,6 +16,19 @@ | */ - 'headers' => [] + 'headers' => [], + /* + |-------------------------------------------------------------------------- + | Driver + |-------------------------------------------------------------------------- + | + | Here you may specify the driver to use for storing redirects. + | + | Supported: "file", "database" + | + */ + + 'driver' => 'file', + // 'driver' => 'database', ]; diff --git a/package.json b/package.json index 1891427..900e75e 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "private": true, + "type": "module", "scripts": { "dev": "vite", - "build": "vite build" + "build": "vite build --mode production" }, "devDependencies": { - "@vitejs/plugin-vue2": "^2.2.0", - "laravel-vite-plugin": "^0.7.2", - "vite": "^4.0.0" + "@statamic/cms": "file:./vendor/statamic/cms/resources/dist-package", + "laravel-vite-plugin": "^2.0.1", + "vite": "^7.3.1" } } diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..2652880 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,20 @@ + + + + + ./tests/Feature + + + ./tests/Unit + + + + + + + + diff --git a/resources/blueprints/redirects.yaml b/resources/blueprints/redirects.yaml index 89d8fd7..dcc47ca 100644 --- a/resources/blueprints/redirects.yaml +++ b/resources/blueprints/redirects.yaml @@ -60,6 +60,19 @@ tabs: hide_display: false width: 50 default: '301' + - + handle: is_regex + field: + type: toggle + display: 'Is Regex?' + instructions: 'Automatically detected, but can be overridden.' + listable: hidden + instructions_position: above + visibility: visible + replicator_preview: true + hide_display: false + width: 50 + default: false - handle: sites field: diff --git a/resources/boost/skills/crud-redirects/SKILL.md b/resources/boost/skills/crud-redirects/SKILL.md new file mode 100644 index 0000000..3f7b7c3 --- /dev/null +++ b/resources/boost/skills/crud-redirects/SKILL.md @@ -0,0 +1,139 @@ +--- +name: CRUD Redirects and Query Strings +description: The ability to CRUD redirects and query strings within the Alt Redirect addon. +--- + +### Domain Description +The Alt Redirect addon provides a flexible way to manage redirects and query strings in Statamic. It supports both file-based and database-based storage via a repository pattern. + +### Key Components +- `AltDesign\AltRedirect\Contracts\RepositoryInterface`: The primary interface for data operations. +- `AltDesign\AltRedirect\Repositories\RepositoryManager`: Handles driver resolution. + +### Basic CRUD Operations +You can perform operations on two types: `redirects` and `query-strings`. + +#### Injecting the Repository +The `RepositoryInterface` is automatically bound to the active driver. You can inject it into your classes: + +```php +use AltDesign\AltRedirect\Contracts\RepositoryInterface; + +public function __construct(protected RepositoryInterface $repository) +{ +} +``` + +#### Fetching All Data +To get all redirects or query strings: + +```php +$redirects = $this->repository->all('redirects'); +$queryStrings = $this->repository->all('query-strings'); +``` + +#### Finding a Specific Item +To find a redirect by its `from` path: + +```php +$redirect = $this->repository->find('redirects', 'from', '/old-url'); +``` + +To find a query string by its key: + +```php +$queryString = $this->repository->find('query-strings', 'query_string', 'utm_source'); +``` + +#### Creating or Updating +To save data, pass the type and an array containing the item data. +**IMPORTANT:** Always provide an `id` (e.g., using `uniqid()`) when creating new items to ensure compatibility across all storage drivers and to allow for future updates. + +**Redirect Example:** +```php +$this->repository->save('redirects', [ + 'id' => uniqid(), + 'from' => '/example-from', + 'to' => '/example-to', + 'redirect_type' => '301', + 'sites' => ['default'] +]); +``` + +**Query String Example:** +```php +$this->repository->save('query-strings', [ + 'id' => uniqid(), + 'query_string' => 'new_param', + 'strip' => true, + 'sites' => ['default'] +]); +``` + +#### Deleting +To delete an item, pass the type and an array containing at least the `id`, `from`, or `query_string` key. + +```php +// Delete redirect by ID +$this->repository->delete('redirects', ['id' => '69d767de74f05']); + +// Delete redirect by path +$this->repository->delete('redirects', ['from' => '/example-from']); + +// Delete query string by name +$this->repository->delete('query-strings', ['query_string' => 'new_param']); +``` + +### Advanced Operations +#### Regex Redirects +To get only the redirects that are intended for regex matching (often those containing regex patterns in the `from` field): + +```php +$regexRedirects = $this->repository->getRegex('redirects'); +``` + +#### Bulk Saving +To save multiple items at once: + +```php +$this->repository->saveAll('redirects', [ + ['from' => '/a', 'to' => '/b', 'redirect_type' => '301', 'sites' => ['default']], + ['from' => '/c', 'to' => '/d', 'redirect_type' => '302', 'sites' => ['default']], +]); +``` + +### Verifying Operations +After creating or modifying a redirect, you can verify it in several ways: + +#### 1. Direct Storage Check +- **File Driver**: Check for a YAML file in `content/alt-redirect/`. The filename is typically a hash of the `from` URL or its base64 encoding. +- **Database Driver**: Use a database tool to query the `alt_redirects` or `alt_query_strings` tables. + +#### 2. Using CURL +You can test if the redirect is working by making a request to the `from` path: +```bash +curl -I http://your-site.test/old-url +``` +A successful redirect should return a `301 Moved Permanently` or `302 Found` status with a `Location` header pointing to the new URL. + +#### 3. Repository Check +You can also use the repository's `find` or `all` methods in a test or via Tinker to confirm the data was saved correctly. + +### Site Configuration +Redirects and query strings are site-specific. You must provide a list of site handles in the `sites` array. + +#### Obtaining Site List +To get the list of available site handles in the current Statamic installation, you can use Tinker: +```php +php artisan tinker --execute="print_r(Statamic\Facades\Site::all()->map->handle()->toArray())" +``` +Or within your code: +```php +use Statamic\Facades\Site; +$siteHandles = Site::all()->map->handle()->all(); +``` + +### Implementation Details +- **File Driver**: Stores data as YAML files in `content/alt-redirect/`. +- **Database Driver**: Uses `alt_redirects` and `alt_query_strings` tables. +- **Multisite**: Both types include a `sites` array which should contain the handles of the Statamic sites where the redirect or query string rule applies. diff --git a/resources/dist/build/assets/alt-redirect-addon-4ed993c7.js b/resources/dist/build/assets/alt-redirect-addon-4ed993c7.js deleted file mode 100644 index 8b13789..0000000 --- a/resources/dist/build/assets/alt-redirect-addon-4ed993c7.js +++ /dev/null @@ -1 +0,0 @@ - diff --git a/resources/dist/build/assets/alt-redirect-addon-DkO3A2Nf.js b/resources/dist/build/assets/alt-redirect-addon-DkO3A2Nf.js new file mode 100644 index 0000000..469bf26 --- /dev/null +++ b/resources/dist/build/assets/alt-redirect-addon-DkO3A2Nf.js @@ -0,0 +1 @@ +const N=window.Vue,{BaseTransition:ve,BaseTransitionPropsValidators:fe,Comment:Se,DeprecationTypes:ye,EffectScope:he,ErrorCodes:Ce,ErrorTypeStrings:we,Fragment:R,KeepAlive:be,ReactiveEffect:xe,Static:Te,Suspense:Pe,Teleport:ke,Text:Re,TrackOpTypes:Ve,Transition:Ae,TransitionGroup:Me,TriggerOpTypes:Ie,VueElement:Ee,__esModule:Le,assertNumber:De,callWithAsyncErrorHandling:He,callWithErrorHandling:Fe,camelize:_e,capitalize:Be,cloneVNode:Ue,compatUtils:ze,compile:Oe,computed:L,createApp:qe,createBlock:$e,createCommentVNode:j,createElementBlock:c,createElementVNode:e,createHydrationRenderer:Ge,createPropsRestProxy:Ne,createRenderer:je,createSSRApp:Ke,createSlots:We,createStaticVNode:Qe,createTextVNode:S,createVNode:l,customRef:Ye,defineAsyncComponent:Je,defineComponent:Xe,defineCustomElement:Ze,defineEmits:et,defineExpose:tt,defineModel:rt,defineOptions:lt,defineProps:ot,defineSSRCustomElement:st,defineSlots:nt,devtools:at,effect:it,effectScope:ut,getCurrentInstance:dt,getCurrentScope:ct,getCurrentWatcher:pt,getTransitionRawChildren:mt,guardReactiveProps:gt,h:vt,handleError:ft,hasInjectionContext:St,hydrate:yt,hydrateOnIdle:ht,hydrateOnInteraction:Ct,hydrateOnMediaQuery:wt,hydrateOnVisible:bt,initCustomFormatter:xt,initDirectivesForSSR:Tt,inject:Pt,isMemoSame:kt,isProxy:Rt,isReactive:Vt,isReadonly:At,isRef:Mt,isRuntimeOnly:It,isShallow:Et,isVNode:Lt,markRaw:Dt,mergeDefaults:Ht,mergeModels:Ft,mergeProps:_t,nextTick:Bt,nodeOps:Ut,normalizeClass:K,normalizeProps:zt,normalizeStyle:Ot,onActivated:qt,onBeforeMount:$t,onBeforeUnmount:Gt,onBeforeUpdate:Nt,onDeactivated:jt,onErrorCaptured:Kt,onMounted:Wt,onRenderTracked:Qt,onRenderTriggered:Yt,onScopeDispose:Jt,onServerPrefetch:Xt,onUnmounted:Zt,onUpdated:er,onWatcherCleanup:tr,openBlock:p,patchProp:rr,popScopeId:lr,provide:or,proxyRefs:sr,pushScopeId:nr,queuePostFlushCb:ar,reactive:ir,readonly:ur,ref:i,registerRuntimeCompiler:dr,render:cr,renderList:V,renderSlot:pr,resolveComponent:mr,resolveDirective:gr,resolveDynamicComponent:vr,resolveFilter:fr,resolveTransitionHooks:Sr,setBlockTracking:yr,setDevtoolsHook:hr,setTransitionHooks:Cr,shallowReactive:wr,shallowReadonly:br,shallowRef:xr,ssrContextKey:Tr,ssrUtils:Pr,stop:kr,toDisplayString:u,toHandlerKey:Rr,toHandlers:Vr,toRaw:Ar,toRef:Mr,toRefs:Ir,toValue:Er,transformVNodeArgs:Lr,triggerRef:Dr,unref:o,useAttrs:Hr,useCssModule:Fr,useCssVars:_r,useHost:Br,useId:Ur,useModel:zr,useSSRContext:Or,useShadowRoot:qr,useSlots:$r,useTemplateRef:Gr,useTransitionState:Nr,vModelCheckbox:jr,vModelDynamic:Kr,vModelRadio:Wr,vModelSelect:W,vModelText:Qr,vShow:Yr,version:Jr,warn:Xr,watch:D,watchEffect:Zr,watchPostEffect:el,watchSyncEffect:tl,withAsyncContext:rl,withCtx:d,withDefaults:ll,withDirectives:Q,withKeys:ol,withMemo:sl,withModifiers:nl,withScopeId:al}=N,{Alert:il,AuthCard:ul,Avatar:dl,Badge:cl,Button:y,ButtonGroup:pl,Calendar:ml,Card:A,CardList:gl,CardListItem:vl,CardPanel:fl,CharacterCounter:Sl,Checkbox:yl,CheckboxGroup:hl,CodeEditor:Cl,Combobox:wl,CommandPaletteItem:bl,ConfirmationModal:xl,Context:Tl,ContextFooter:Pl,ContextHeader:kl,ContextItem:Rl,ContextLabel:Vl,ContextMenu:Al,ContextSeparator:Ml,CreateForm:Il,DatePicker:El,DateRangePicker:Ll,Description:Dl,DocsCallout:Hl,DragHandle:Fl,Dropdown:_l,DropdownItem:Bl,DropdownLabel:Ul,DropdownMenu:zl,DropdownSeparator:Ol,DropdownFooter:ql,DropdownHeader:$l,Editable:Gl,ErrorMessage:Nl,EmptyStateItem:jl,EmptyStateMenu:Kl,Field:Wl,Header:Y,Heading:H,HoverCard:Ql,Icon:Yl,Input:F,InputGroup:Jl,InputGroupAppend:Xl,InputGroupPrepend:Zl,Label:eo,Listing:to,ListingCustomizeColumns:ro,ListingFilters:lo,ListingHeaderCell:oo,ListingPagination:so,ListingPresets:no,ListingPresetTrigger:ao,ListingRowActions:io,ListingSearch:uo,ListingTable:co,ListingTableBody:po,ListingTableHead:mo,ListingToggleAll:go,LivePreview:vo,LivePreviewPopout:fo,MiddleEllipsis:So,Modal:yo,ModalClose:ho,ModalTitle:Co,Pagination:J,Panel:wo,PanelFooter:bo,PanelHeader:xo,Popover:To,PublishComponents:Po,PublishContainer:X,publishContextKey:ko,injectPublishContext:Ro,PublishField:Vo,PublishFields:Ao,PublishFieldsProvider:Mo,PublishForm:Io,PublishLocalizations:Eo,PublishSections:Lo,PublishTabs:Do,Radio:Ho,RadioGroup:Fo,Select:_o,Separator:Bo,Slider:Uo,Skeleton:zo,SplitterGroup:Oo,SplitterPanel:qo,SplitterResizeHandle:$o,StatusIndicator:Go,Subheading:_,Switch:Z,TabContent:No,Stack:jo,StackClose:Ko,StackHeader:Wo,StackFooter:Qo,StackContent:Yo,Table:Jo,TableCell:Xo,TableColumn:Zo,TableColumns:es,TableRow:ts,TableRows:rs,TabList:ls,TabProvider:os,Tabs:ss,TabTrigger:ns,Text:as,Textarea:is,TimePicker:us,ToggleGroup:ds,ToggleItem:cs,Widget:ps,registerIconSet:ms,registerIconSetFromStrings:gs}=__STATAMIC__.ui,{AfterSaveHooks:ee,BeforeSaveHooks:te,Pipeline:re,PipelineStopped:vs,Request:le}=__STATAMIC__.savePipeline,{Form:fs,Head:Ss,Link:ys,router:h,toggleArchitecturalBackground:hs,useArchitecturalBackground:Cs,useForm:ws,usePoll:bs}=__STATAMIC__.inertia,oe={id:"alt-redirect"},se={class:"text-sm"},ne={class:"my-4 pb-2 px-4 flex items-center gap-4"},ae={class:"flex items-center gap-2 shrink-0"},ie=["value"],ue={class:"px-2"},de={key:0,"data-size":"sm",tabindex:"0",class:"data-table",style:{"table-layout":"fixed"}},ce={key:1,"data-size":"sm",tabindex:"0",class:"data-table",style:{"table-layout":"fixed"}},pe=["href"],me={class:"flex justify-between items-center"},ge={__name:"AltRedirect",props:{title:String,instructions:String,action:String,blueprint:Array,initialMeta:Array,initialValues:Array,items:Array,type:String},setup(n){const g=n,m=i(10),B=[10,25,50,100,500],a=i(1),C=i(null),v=i(""),x=i({...g.initialValues}),U=i(g.initialMeta),M=i({}),I=i(!1),E=i("container"),T=L(()=>Math.max(1,Math.ceil(w.value.total/m.value))),w=L(()=>{let s=g.items;v.value?.length>0&&(s=s.filter(P=>Object.values(P).map(k=>k?.toString().toLowerCase()).some(k=>k?.includes(v.value.toLowerCase()))));const r=(a.value-1)*m.value,t=r+m.value;return{total:s.length,data:s.slice(r,t)}});function f(s){s>T.value&&(s=T.value),a.value=s}function z(s,r){confirm("Are you sure you want to delete this redirect?")&&h.post(cp_url("alt-design/alt-redirect/delete"),{from:s,id:r},{preserveState:"errors",preserveScroll:!0,onSuccess:()=>{Statamic.$toast.success("Redirect deleted successfully!"),f(a.value)}})}function O(s){confirm("Are you sure you want to delete this query string?")&&h.post(cp_url("/alt-design/alt-redirect/query-strings/delete"),{query_string:s},{preserveState:"errors",preserveScroll:!0,onSuccess:()=>{Statamic.$toast.success("Query string deleted successfully!"),f(a.value)}})}function q(){if(!C.value){alert("You haven't attached a CSV file!");return}h.post(cp_url("alt-design/alt-redirect/import"),{file:C.value},{preserveState:"errors",preserveScroll:!0,onSuccess:()=>{Statamic.$toast.success("CSV imported successfully!"),f(a.value),C.value=null}})}function $(s,r){h.post(cp_url("/alt-design/alt-redirect/query-strings/toggle"),{index:s,toggleKey:r},{preserveState:"errors",preserveScroll:!0,onSuccess:()=>{Statamic.$toast.success("Toggled successfully!"),f(a.value)}})}function G(){new re().provide({container:E,errors:M,saving:I}).through([new te("alt-redirect"),new le(cp_url("/alt-design/alt-redirect"),"POST",{type:g.type}),new ee("alt-redirect")]).then(()=>{x.value={...g.initialValues},Statamic.$toast.success("Redirect added successfully!"),h.reload()})}return D(v,()=>{a.value=1}),D(m,()=>{a.value=1}),(s,r)=>(p(),c("div",oe,[l(o(Y),{title:n.title},{title:d(()=>[e("div",null,[S(u(n.title)+" ",1),e("div",se,u(n.instructions),1)])]),default:d(()=>[l(o(y),{text:"Save",variant:"primary",disabled:I.value,onClick:G},null,8,["disabled"])]),_:1},8,["title"]),l(o(X),{ref_key:"container",ref:E,action:n.action,blueprint:n.blueprint,meta:U.value,modelValue:x.value,"onUpdate:modelValue":r[0]||(r[0]=t=>x.value=t),errors:M.value},null,8,["action","blueprint","meta","modelValue","errors"]),l(o(A),{class:"overflow-hidden p-0"},{default:d(()=>[e("div",ne,[l(o(F),{modelValue:v.value,"onUpdate:modelValue":r[1]||(r[1]=t=>v.value=t),placeholder:"Search",class:"flex-1"},null,8,["modelValue"]),e("div",ae,[r[5]||(r[5]=e("span",{class:"text-sm text-gray-500"},"Per page",-1)),Q(e("select",{"onUpdate:modelValue":r[2]||(r[2]=t=>m.value=t),class:"input-text text-sm"},[(p(),c(R,null,V(B,t=>e("option",{key:t,value:t},u(t),9,ie)),64))],512),[[W,m.value,void 0,{number:!0}]])])]),e("div",ue,[n.type=="redirects"?(p(),c("table",de,[r[6]||(r[6]=e("thead",null,[e("tr",null,[e("th",{class:"group from-column sortable-column",style:{width:"33%"}},[e("span",null,"From")]),e("th",{class:"group from-column sortable-column pr-8 w-24",style:{width:"33%"}},[e("span",null,"To")]),e("th",{class:"group to-column pr-8",style:{width:"8%"}},[e("span",null,"Type")]),e("th",{class:"group to-column pr-8",style:{width:"15%"}},[e("span",null,"Sites")]),e("th",{class:"actions-column",style:{width:"13.4%"}})])],-1)),e("tbody",null,[(p(!0),c(R,null,V(w.value.data,t=>(p(),c("tr",{key:t.id,style:{width:"100%",overflow:"clip"}},[e("td",null,u(t.from),1),e("td",null,u(t.to),1),e("td",null,u(t.redirect_type),1),e("td",null,u(t.sites&&t.sites.length?t.sites.join(", "):"Unknown"),1),e("td",null,[l(o(y),{icon:"trash",size:"sm",onClick:P=>z(t.from,t.id),text:"Remove",variant:"danger"},null,8,["onClick"])])]))),128))])])):n.type=="query-strings"?(p(),c("table",ce,[r[7]||(r[7]=e("thead",null,[e("tr",null,[e("th",{class:"group from-column sortable-column",style:{width:"46%"}},[e("span",null,"Query String Key")]),e("th",{class:"group to-column pr-8",style:{width:"20%"}},[e("span",null,"Strip")]),e("th",{class:"group to-column pr-8",style:{width:"20.6%"}},[e("span",null,"Sites")]),e("th",{class:"actions-column",style:{width:"13.4%"}})])],-1)),e("tbody",null,[(p(!0),c(R,null,V(w.value.data,(t,P)=>(p(),c("tr",{key:t.id,style:{width:"100%",overflow:"clip"}},[e("td",null,u(t.query_string),1),e("td",null,[l(o(Z),{modelValue:t.strip,"onUpdate:modelValue":[b=>t.strip=b,b=>$(t.query_string,"strip")]},null,8,["modelValue","onUpdate:modelValue"])]),e("td",null,u(t.sites&&t.sites.length?t.sites.join(", "):"Unknown"),1),e("td",null,[l(o(y),{icon:"trash",size:"sm",onClick:b=>O(t.query_string),text:"Remove",variant:"danger"},null,8,["onClick"])])]))),128))])])):j("",!0)]),l(o(J),{"resource-meta":{current_page:a.value,last_page:T.value,total:w.value.total},"show-totals":!1,"show-per-page-selector":!1,onPageSelected:f},null,8,["resource-meta"])]),_:1}),e("div",{class:K(["flex justify-between",{hidden:n.type=="query-strings"}])},[l(o(A),{class:"w-full xl:w-1/2 card overflow-hidden p-0 mb-4 mt-4 mr-4 px-4 py-4"},{default:d(()=>[e("header",null,[l(o(H),null,{default:d(()=>[...r[8]||(r[8]=[S("CSV Export",-1)])]),_:1}),l(o(_),null,{default:d(()=>[...r[9]||(r[9]=[S("Exports CSV of all redirects, use this format on import.",-1)])]),_:1})]),e("a",{class:"btn-primary",href:s.cp_url("/alt-design/alt-redirect/export"),download:""},[l(o(y),{text:"Export CSV"})],8,pe)]),_:1}),l(o(A),{class:"w-full xl:w-1/2 card overflow-hidden p-0 mb-4 mt-4 ml-4 px-4 py-4"},{default:d(()=>[e("header",null,[l(o(H),null,{default:d(()=>[...r[10]||(r[10]=[S("CSV Import",-1)])]),_:1}),l(o(_),null,{default:d(()=>[...r[11]||(r[11]=[S("Import CSV for redirects, use the export format on import.",-1)])]),_:1})]),e("div",me,[l(o(F),{type:"file",onInput:r[3]||(r[3]=t=>C.value=t.target.files[0])}),l(o(y),{onClick:r[4]||(r[4]=t=>q()),text:"Import"})])]),_:1})],2)]))}};Statamic.booting(()=>{Statamic.$inertia.register("alt-redirect::Index",ge)}); diff --git a/resources/dist/build/assets/alt-redirect-addon-d5a1e6a6.js b/resources/dist/build/assets/alt-redirect-addon-d5a1e6a6.js deleted file mode 100644 index c8ee705..0000000 --- a/resources/dist/build/assets/alt-redirect-addon-d5a1e6a6.js +++ /dev/null @@ -1 +0,0 @@ -function f(a,t,e,s,n,i,c,p){var r=typeof a=="function"?a.options:a;t&&(r.render=t,r.staticRenderFns=e,r._compiled=!0),s&&(r.functional=!0),i&&(r._scopeId="data-v-"+i);var o;if(c?(o=function(l){l=l||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext,!l&&typeof __VUE_SSR_CONTEXT__<"u"&&(l=__VUE_SSR_CONTEXT__),n&&n.call(this,l),l&&l._registeredComponents&&l._registeredComponents.add(c)},r._ssrRegister=o):n&&(o=p?function(){n.call(this,(r.functional?this.parent:this).$root.$options.shadowRoot)}:n),o)if(r.functional){r._injectStyles=o;var h=r.render;r.render=function(m,u){return o.call(u),h(m,u)}}else{var d=r.beforeCreate;r.beforeCreate=d?[].concat(d,o):[o]}return{exports:a,options:r}}const g={props:{title:String,instructions:String,action:String,blueprint:Array,meta:Array,redirectTo:String,values:Array,data:Array,items:Array,type:String},computed:{lastPage(){return Math.ceil(this.totalItems/this.perPage)}},data(){return{itemsReady:[],itemsSliced:[],perPage:10,currentPage:1,totalItems:0,selectedFile:null,search:"",fileName:"Choose a file...",selectedPage:""}},watch:{search:{immediate:!0,handler(){this.sliceItems()}}},mounted(){this.itemsReady=this.items,this.totalItems=this.items.length,this.sliceItems()},methods:{updateItems(a){this.itemsReady=a.data.data,this.totalItems=a.data.data.length,this.sliceItems(),this.$forceUpdate()},setPage(a){this.currentPage=a,this.sliceItems()},sliceItems(){let a=this.itemsReady;this.search&&(a=a.filter(s=>Object.values(s).map(i=>i.toString().toLowerCase()).some(i=>i.includes(this.search.toLowerCase()))));const t=(this.currentPage-1)*this.perPage,e=t+this.perPage;this.totalItems=a.length,this.itemsSliced=a.slice(t,e)},deleteRedirect(a,t){confirm("Are you sure you want to delete this redirect?")&&Statamic.$axios.post(cp_url("alt-design/alt-redirect/delete"),{from:a,id:t}).then(e=>{this.updateItems(e)}).catch(e=>{console.log(e)})},deleteQueryString(a){confirm("Are you sure you want to delete this query string?")&&Statamic.$axios.post(cp_url("/alt-design/alt-redirect/query-strings/delete"),{query_string:a}).then(t=>{this.updateItems(t)}).catch(t=>{console.log(t)})},importFromCSV(){if(!this.selectedFile){alert("You haven't attached a CSV file!");return}var a=new FormData;a.append("file",this.selectedFile),a.append("data",JSON.stringify(this.itemsReady)),Statamic.$axios.post(cp_url("alt-design/alt-redirect/import"),a,{headers:{"Content-Type":"multipart/form-data"}}).then(t=>{location.reload()}).catch(t=>{console.log(t)})},handleFileUpload(a){this.selectedFile=a.target.files[0],this.fileName=this.selectedFile?this.selectedFile.name:"Choose a file..."},dropdownPageChange(){this.setPage(this.selectedPage)},toggleKey(a,t){Statamic.$axios.post(cp_url("/alt-design/alt-redirect/query-strings/toggle"),{index:a,toggleKey:t}).then(e=>{this.updateItems(e)}).catch(e=>{console.log(e)})}}};var _=function(){var t=this,e=t._self._c;return e("div",{attrs:{id:"alt-redirect"}},[e("h1",{staticClass:"flex-1"},[t._v(t._s(t.title))]),e("h2",{staticClass:"flex-1"},[t._v(t._s(t.instructions))]),e("publish-form",{attrs:{title:"",action:t.action,blueprint:t.blueprint,meta:t.meta,values:t.values},on:{saved:function(s){return t.updateItems(s)}}}),e("div",{staticClass:"card overflow-hidden p-0"},[e("div",{staticClass:"mt-4 pb-2 px-4"},[e("input",{directives:[{name:"model",rawName:"v-model",value:t.search,expression:"search"}],staticClass:"input-text",attrs:{type:"text",placeholder:"Search"},domProps:{value:t.search},on:{input:function(s){s.target.composing||(t.search=s.target.value)}}})]),e("div",{staticClass:"px-2"},[t.type=="redirects"?e("table",{staticClass:"data-table",staticStyle:{"table-layout":"fixed"},attrs:{"data-size":"sm",tabindex:"0"}},[t._m(0),e("tbody",t._l(t.itemsSliced,function(s){return e("tr",{key:s.id,staticStyle:{width:"100%",overflow:"clip"}},[e("td",[t._v(" "+t._s(s.from)+" ")]),e("td",[t._v(" "+t._s(s.to)+" ")]),e("td",[t._v(" "+t._s(s.redirect_type)+" ")]),e("td",[t._v(" "+t._s(s.sites&&s.sites.length?s.sites.join(", "):"Unknown")+" ")]),e("td",[e("button",{staticClass:"btn",staticStyle:{color:"#bc2626"},on:{click:function(n){return t.deleteRedirect(s.from,s.id)}}},[t._v("Remove ")])])])}),0)]):t._e(),t.type=="query-strings"?e("table",{staticClass:"data-table",staticStyle:{"table-layout":"fixed"},attrs:{"data-size":"sm",tabindex:"0"}},[t._m(1),e("tbody",t._l(t.itemsSliced,function(s,n){return e("tr",{key:s.id,staticStyle:{width:"100%",overflow:"clip"}},[e("td",[t._v(" "+t._s(s.query_string)+" ")]),e("td",[e("button",{staticClass:"toggle-container",class:{on:s.strip},attrs:{type:"button","aria-pressed":"false","aria-label":"Toggle Button",id:"field_preserve"},on:{click:function(i){return t.toggleKey(s.query_string,"strip")}}},[t._m(2,!0)])]),e("td",[t._v(" "+t._s(s.sites&&s.sites.length?s.sites.join(", "):"Unknown")+" ")]),e("td",[e("button",{staticClass:"btn",staticStyle:{color:"#bc2626"},on:{click:function(i){return t.deleteQueryString(s.query_string)}}},[t._v("Remove ")])])])}),0)]):t._e()]),e("div",{staticClass:"pagination text-sm py-4 px-4 flex items-center justify-between"},[e("div",{staticClass:"w-1/3 flex items-center"},[t._v(" Page "),e("span",{staticClass:"font-semibold mx-1",domProps:{innerHTML:t._s(t.currentPage)}}),t._v(" of "),e("span",{staticClass:"mx-1",domProps:{innerHTML:t._s(t.lastPage)}})]),e("div",{staticClass:"w-1/3 flex items-center justify-center"},[e("span",{staticClass:"cursor-pointer",staticStyle:{height:"15px",margin:"0 15px",width:"12px"},on:{click:function(s){return t.setPage(t.currentPage-1>0?t.currentPage-1:1)}}},[e("svg",{attrs:{xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",width:"205",height:"205",viewBox:"0 0 205 205"}},[e("defs",[e("clipPath",{attrs:{id:"clip-LEFT"}},[e("rect",{attrs:{width:"205",height:"205"}})])]),e("g",{attrs:{id:"LEFT","clip-path":"url(#clip-LEFT)"}},[e("rect",{attrs:{width:"205",height:"205",fill:"#fff"}}),e("path",{attrs:{stroke:"#2e9fff",fill:"#2e9fff",id:"Icon_awesome-arrow-left","data-name":"Icon awesome-arrow-left",d:"M114.961,184.524l-9.91,9.91a10.669,10.669,0,0,1-15.132,0L3.143,107.7a10.669,10.669,0,0,1,0-15.132L89.919,5.794a10.669,10.669,0,0,1,15.132,0l9.91,9.91a10.725,10.725,0,0,1-.179,15.311L60.994,82.259H189.283A10.687,10.687,0,0,1,200,92.972v14.284a10.687,10.687,0,0,1-10.713,10.713H60.994l53.789,51.244A10.648,10.648,0,0,1,114.961,184.524Z",transform:"translate(2.004 2.353)"}})])])]),t.currentPage>1?e("span",{staticClass:"cursor-pointer py-1 mx-1",on:{click:function(s){return t.setPage(1)}}},[t._v("1")]):t._e(),t.currentPage==1?e("span",{staticClass:"cursor-pointer py-1 mx-1 font-semibold",on:{click:function(s){return t.setPage(1)}}},[t._v("1")]):t._e(),t.currentPage>3?e("span",[t._v("...")]):t._e(),t.currentPage>2?e("span",{staticClass:"cursor-pointer py-1 mx-1",on:{click:function(s){return t.setPage(t.currentPage-1)}}},[t._v(t._s(t.currentPage-1))]):t._e(),t.currentPage!==1&&t.currentPage!==t.lastPage?e("span",{staticClass:"cursor-pointer py-1 mx-1 font-semibold"},[t._v(t._s(t.currentPage))]):t._e(),t.currentPage{Statamic.$components.register("alt-redirect",w)}); diff --git a/resources/dist/build/assets/alt-redirect-addon-tn0RQdqM.css b/resources/dist/build/assets/alt-redirect-addon-tn0RQdqM.css new file mode 100644 index 0000000..e69de29 diff --git a/resources/dist/build/manifest.json b/resources/dist/build/manifest.json index e3a4be2..704e40b 100644 --- a/resources/dist/build/manifest.json +++ b/resources/dist/build/manifest.json @@ -1,12 +1,17 @@ { "resources/css/alt-redirect-addon.css": { - "file": "assets/alt-redirect-addon-4ed993c7.js", + "file": "assets/alt-redirect-addon-tn0RQdqM.css", + "src": "resources/css/alt-redirect-addon.css", "isEntry": true, - "src": "resources/css/alt-redirect-addon.css" + "name": "alt-redirect-addon", + "names": [ + "alt-redirect-addon.css" + ] }, "resources/js/alt-redirect-addon.js": { - "file": "assets/alt-redirect-addon-d5a1e6a6.js", - "isEntry": true, - "src": "resources/js/alt-redirect-addon.js" + "file": "assets/alt-redirect-addon-DkO3A2Nf.js", + "name": "alt-redirect-addon", + "src": "resources/js/alt-redirect-addon.js", + "isEntry": true } } \ No newline at end of file diff --git a/resources/js/alt-redirect-addon.js b/resources/js/alt-redirect-addon.js index 94fa865..b4f2e73 100644 --- a/resources/js/alt-redirect-addon.js +++ b/resources/js/alt-redirect-addon.js @@ -1,5 +1,5 @@ import AltRedirect from './components/AltRedirect.vue'; Statamic.booting(() => { - Statamic.$components.register('alt-redirect', AltRedirect); + Statamic.$inertia.register('alt-redirect::Index', AltRedirect); }); diff --git a/resources/js/components/AltRedirect.vue b/resources/js/components/AltRedirect.vue index 68e2800..021400c 100644 --- a/resources/js/components/AltRedirect.vue +++ b/resources/js/components/AltRedirect.vue @@ -1,151 +1,173 @@ - diff --git a/resources/views/index.blade.php b/resources/views/index.blade.php deleted file mode 100644 index 412df92..0000000 --- a/resources/views/index.blade.php +++ /dev/null @@ -1,20 +0,0 @@ -@extends('statamic::layout') - -@section('content') -
- - - -
- - -@endsection diff --git a/routes/cp.php b/routes/cp.php index 1133017..f594bcd 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -1,7 +1,8 @@ ['statamic.cp.authenticated'], 'namespace' => 'AltDesign\AltRedirect\Http\Controllers'], function() { +Route::group(['middleware' => ['statamic.cp.authenticated'], 'namespace' => 'AltDesign\AltRedirect\Http\Controllers'], function () { // Settings Route::get('/alt-design/alt-redirect/', 'AltRedirectController@index')->name('alt-redirect.index'); Route::post('/alt-design/alt-redirect/', 'AltRedirectController@create')->name('alt-redirect.create'); diff --git a/src/Console/Commands/DefaultQueryStringsCommand.php b/src/Console/Commands/DefaultQueryStringsCommand.php index 06e43e6..a718921 100644 --- a/src/Console/Commands/DefaultQueryStringsCommand.php +++ b/src/Console/Commands/DefaultQueryStringsCommand.php @@ -26,7 +26,7 @@ class DefaultQueryStringsCommand extends Command */ public function handle() { - if(!$this->confirm('Do you wish to (re)create the list of default query strings?')) { + if (! $this->confirm('Do you wish to (re)create the list of default query strings?')) { $this->error('User aborted Command'); } diff --git a/src/Console/Commands/MigrateFileRedirectsCommand.php b/src/Console/Commands/MigrateFileRedirectsCommand.php new file mode 100644 index 0000000..3e7c8db --- /dev/null +++ b/src/Console/Commands/MigrateFileRedirectsCommand.php @@ -0,0 +1,75 @@ +error('The required database tables do not exist.'); + $this->warn('Please run the following commands to set up the database:'); + $this->line('1. php artisan vendor:publish --tag=alt-redirect-migrations'); + $this->line('2. php artisan migrate'); + + return 1; + } + + if (! $this->confirm('This will migrate all file-based redirects and query strings to the database. Do you wish to continue?')) { + $this->error('User aborted command.'); + + return 1; + } + + $fileRepository = new FileRepository; + $databaseRepository = new DatabaseRepository; + + $this->info('Migrating redirects...'); + $redirects = $fileRepository->all('redirects'); + if (count($redirects) > 0) { + foreach ($redirects as $redirect) { + $databaseRepository->save('redirects', $redirect); + } + $this->info(count($redirects).' redirects migrated.'); + } else { + $this->info('No redirects found to migrate.'); + } + + $this->info('Migrating query strings...'); + $queryStrings = $fileRepository->all('query-strings'); + if (count($queryStrings) > 0) { + foreach ($queryStrings as $queryString) { + $databaseRepository->save('query-strings', $queryString); + } + $this->info(count($queryStrings).' query strings migrated.'); + } else { + $this->info('No query strings found to migrate.'); + } + + $this->info('Migration completed successfully!'); + + return 0; + } +} diff --git a/src/Console/Commands/ReScanRegexCommand.php b/src/Console/Commands/ReScanRegexCommand.php new file mode 100644 index 0000000..09af0e0 --- /dev/null +++ b/src/Console/Commands/ReScanRegexCommand.php @@ -0,0 +1,44 @@ +all('redirects'); + + $count = 0; + foreach ($redirects as $redirect) { + if (! isset($redirect['from'])) { + $this->error('Redirect missing "from" key: ' . ($redirect['id'] ?? 'unknown ID')); + continue; + } + + $isRegexBefore = (bool) ($redirect['is_regex'] ?? false); + $isRegexAfter = URISupport::isRegex($redirect['from']); + + if ($isRegexBefore !== $isRegexAfter) { + $redirect['is_regex'] = $isRegexAfter; + $repository->save('redirects', $redirect); + $count++; + } + } + + $this->info("Successfully updated $count redirects."); + } + + protected function isRegex(string $str): bool + { + return URISupport::isRegex($str); + } +} diff --git a/src/Contracts/RepositoryInterface.php b/src/Contracts/RepositoryInterface.php new file mode 100644 index 0000000..849fa40 --- /dev/null +++ b/src/Contracts/RepositoryInterface.php @@ -0,0 +1,18 @@ +string('id')->primary(); + $table->string('from')->index(); + $table->string('to'); + $table->integer('redirect_type')->default(301); + $table->boolean('is_regex')->default(false); + $table->json('sites')->nullable(); + $table->timestamps(); + }); + + Schema::create('alt_query_strings', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('query_string')->index(); + $table->boolean('strip')->default(false); + $table->json('sites')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('alt_redirects'); + Schema::dropIfExists('alt_query_strings'); + } +}; diff --git a/src/Database/Migrations/2024_04_09_000001_seed_default_query_strings.php b/src/Database/Migrations/2024_04_09_000001_seed_default_query_strings.php new file mode 100644 index 0000000..0e7ca1f --- /dev/null +++ b/src/Database/Migrations/2024_04_09_000001_seed_default_query_strings.php @@ -0,0 +1,23 @@ +makeDefaultQueryStrings(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // No need to remove them on down, they are part of the table data + } +}; diff --git a/src/Helpers/Data.php b/src/Helpers/Data.php deleted file mode 100644 index 531937b..0000000 --- a/src/Helpers/Data.php +++ /dev/null @@ -1,129 +0,0 @@ - [ - 'content/alt-redirect', - 'content/alt-redirect/alt-regex' - ], - 'query-strings' => [ - 'content/alt-redirect/query-strings' - ], - ]; - - public function __construct($type, $onlyRegex = false) - { - $this->type = $type; - - // New up Stat File Manager - $this->manager = new Manager(); - - // Check redirect folder exists - $this->checkOrMakeDirectories(); - - if(!$onlyRegex) { - $allRedirects = []; - foreach($this->types[$this->type] as $path) { - $filePath = base_path($path); - $allRedirects = array_merge($allRedirects, File::files($filePath)); - } - - $allRedirects = collect($allRedirects)->sortByDesc(function ($file) { - return $file->getCTime(); - }); - foreach ($allRedirects as $redirect) { - $data = Yaml::parse(File::get($redirect)); - $this->data[] = $data; - } - } - - $allRegexRedirects = File::allFiles(base_path('/content/alt-redirect/alt-regex')); - $allRegexRedirects = collect($allRegexRedirects)->sortBy(function ($file) { - return $file->getCTime(); - }); - foreach ($allRegexRedirects as $redirect) { - $data = Yaml::parse(File::get($redirect)); - $this->regexData[] = $data; - } - } - - public function checkOrMakeDirectories() - { - foreach($this->types as $type) { - foreach($type as $directory) { - if (!$this->manager->disk()->exists($directory)) { - $this->manager->disk()->makeDirectory($directory); - } - } - } - } - - public function getByKey($key, $value) : array | null - { - $data = collect($this->data); - $result = $data->firstWhere($key, $value); - if ($result) { - return $result; - } - return null; - } - - public function get($key) - { - if (!isset($this->data[$key])) { - return null; - } - return $this->data[$key]; - } - - public function set($key, $value) - { - $this->data[$key] = $value; - - Yaml::dump($this->data, $this->currentFile); - } - - public function all() - { - return $this->data; - } - - public function setAll($data) - { - $this->data = $data; - - switch ($this->type) { - case 'redirects': - if (strpos($data['from'], '(.*)') === false) { - $this->manager->disk()->put('content/alt-redirect/' . hash('sha512', (base64_encode($data['from']))) . '.yaml', Yaml::dump($this->data)); - return; - } - $this->manager->disk()->put('content/alt-redirect/alt-regex/' . hash('sha512', base64_encode($data['id'])) . '.yaml', Yaml::dump($this->data)); - break; - case 'query-strings': - $this->manager->disk()->put('content/alt-redirect/query-strings/' . hash('sha512', (base64_encode($data['query_string']))) . '.yaml', Yaml::dump($this->data)); - break; - } - } - - public function saveAll($data) - { - foreach ($data as $redirect) { - $this->setAll($redirect); - } - } - -} diff --git a/src/Helpers/DefaultQueryStrings.php b/src/Helpers/DefaultQueryStrings.php index f21e4dd..8395fd1 100644 --- a/src/Helpers/DefaultQueryStrings.php +++ b/src/Helpers/DefaultQueryStrings.php @@ -1,39 +1,42 @@ -setDirectory(__DIR__.'/../../resources/blueprints')->find('query-strings'); // Add the values to the array - foreach($this->defaultQueryStrings as $query) { + foreach ($this->defaultQueryStrings as $query) { $fields = $blueprint->fields(); $arr = [ - 'id' => uniqid(), + 'id' => md5($query), 'sites' => ['default'], 'query_string' => $query, + 'strip' => true, ]; $fields = $fields->addValues($arr); $fields->validate(); - $data->setAll($fields->process()->values()->toArray()); + $repository->save('query-strings', $fields->process()->values()->toArray()); } - (new Manager())->disk()->makeDirectory('content/alt-redirect/.installed'); } } diff --git a/src/Helpers/URISupport.php b/src/Helpers/URISupport.php index a96326a..30eba81 100644 --- a/src/Helpers/URISupport.php +++ b/src/Helpers/URISupport.php @@ -4,61 +4,113 @@ namespace AltDesign\AltRedirect\Helpers; -use Illuminate\Support\Arr; +use AltDesign\AltRedirect\Contracts\RepositoryInterface; use Illuminate\Support\Str; class URISupport { /** - * Returns the current URI with named Query Strings filtered out . + * Returns the current path for simple redirect matching. * - * @return string $uri + * @return string $path */ - public static function uriWithFilteredQueryStrings() : string + public static function path(): string { $request = request(); - $data = new Data('query-strings'); - $withoutQueryStrings = Arr::pluck($data->all(), 'query_string'); - // Filter out unwanted params, then strip the base url to get a filtered uri - $encoding = str_contains($request->getRequestURI(), '+') - ? PHP_QUERY_RFC1738 - : PHP_QUERY_RFC3986; return Str::replace( $request->root(), '', - self::fullUrlWithoutQuery($withoutQueryStrings, $encoding) + $request->url() ); } /** - * Replacement for fullUrlWithoutQuery() in Request to use custom Arr::query() implementation + * Returns the current URI with filtered query strings for regex redirect matching. * - * @param array $keys - * @param int $encoding_type (optional) Encoding Type - * @return string + * @param string|null $path Optional path to use instead of current path. + * @return string $uri */ - private static function fullUrlWithoutQuery(array $keys, $encoding_type = PHP_QUERY_RFC1738) : string + public static function uriWithFilteredQueryStrings(?string $path = null): string { $request = request(); - $query = Arr::except($request->query(), $keys); + $path = $path ?? self::path(); + $queryString = $request->getQueryString(); + + if (! $queryString) { + return $path; + } + + $stripKeys = []; + try { + $repository = app(RepositoryInterface::class); + foreach ($repository->all('query-strings') as $item) { + if ($item['strip'] ?? false) { + $stripKeys[] = strtolower($item['query_string']); + } + } + } catch (\Exception $e) { + // Fallback if repository is not available + } + + parse_str($queryString, $params); + $filteredParams = []; + foreach ($params as $key => $value) { + if (! in_array(strtolower((string) $key), $stripKeys)) { + $filteredParams[$key] = $value; + } + } - $question = $request->getBaseUrl().$request->getPathInfo() === '/' ? '/?' : '?'; + if (empty($filteredParams)) { + return $path; + } - return count($query) > 0 - ? $request->url().$question.self::myArrQuery($query, $encoding_type) - : $request->url(); + return $path.'?'.http_build_query($filteredParams); } /** - * Replacement for Arr::query() to use default encoding method + * Returns true if the given string is a regex redirect. * - * @param array $array - * @param int $encoding_type (optional) Encoding Type - * @return string + * @param string $str + * @return bool */ - private static function myArrQuery(array $array, $encoding_type = PHP_QUERY_RFC1738) : string + public static function isRegex(string $str): bool { - return http_build_query($array, '', '&', $encoding_type); + if (empty($str)) { + return false; + } + + // 1. Check if it's a valid delimited PHP regex. + if (@preg_match($str, '') !== false) { + // BUT, if the delimiter is '/', it might just be a simple path. + // Many URLs start and end with '/'. + if (str_starts_with($str, '/') && str_ends_with($str, '/')) { + // High confidence indicators + if (preg_match('/[\*\^\$\|{}\\\\[\]]/', $str)) { + return true; + } + // Grouping with wildcards/quantifiers (e.g. (.+)) + if (preg_match('/\([^\)]*[\.\+\?\*][^\)]*\)/', $str)) { + return true; + } + + return false; + } + + // If it uses other delimiters (like # or ~), assume the user knows what they're doing. + return true; + } + + // 2. Check for naked regex indicators. + // High confidence indicators + if (preg_match('/[\*\^\$\|{}\\\\[\]]/', $str)) { + return @preg_match('#'.$str.'#', '') !== false; + } + // Grouping with wildcards/quantifiers (e.g. (.+)) + if (preg_match('/\([^\)]*[\.\+\?\*][^\)]*\)/', $str)) { + return @preg_match('#'.$str.'#', '') !== false; + } + + return false; } } diff --git a/src/Http/Controllers/AltRedirectController.php b/src/Http/Controllers/AltRedirectController.php index 109b8f7..862c8ac 100644 --- a/src/Http/Controllers/AltRedirectController.php +++ b/src/Http/Controllers/AltRedirectController.php @@ -2,22 +2,24 @@ namespace AltDesign\AltRedirect\Http\Controllers; -use AltDesign\AltRedirect\Helpers\Data; +use AltDesign\AltRedirect\Contracts\RepositoryInterface; use Illuminate\Http\Request; +use Inertia\Inertia; use Statamic\Fields\Blueprint; use Statamic\Fields\BlueprintRepository; -use Statamic\Filesystem\Manager; class AltRedirectController { private string $type = 'redirects'; + private array $actions = [ 'redirects' => 'alt-redirect.create', - 'query-strings' => 'alt-redirect.query-strings.create' + 'query-strings' => 'alt-redirect.query-strings.create', ]; + private array $titles = [ 'redirects' => 'Alt Redirect', - 'query-strings' => 'Alt Redirect - Query Strings' + 'query-strings' => 'Alt Redirect - Query Strings', ]; private array $instructions = [ @@ -28,8 +30,7 @@ class AltRedirectController // Work out what page we're handling public function __construct() { - $path = request()->path(); - if (str_contains($path, 'query-strings')) { + if (collect([request()->path(), request()->input('type')])->filter(fn ($value) => ! empty($value) && str_contains($value, 'query-strings'))->isNotEmpty()) { $this->type = 'query-strings'; } } @@ -39,10 +40,9 @@ public function index() // Grab the old directory just in case $oldDirectory = Blueprint::directory(); - //Publish form + // Publish form // Get an array of values - $data = new Data($this->type); - $values = $data->all(); + $values = app(RepositoryInterface::class)->all($this->type); // Get a blueprint.So $blueprint = with(new BlueprintRepository)->setDirectory(__DIR__.'/../../../resources/blueprints')->find($this->type); @@ -58,11 +58,11 @@ public function index() Blueprint::setDirectory($oldDirectory); } - return view('alt-redirect::index', [ + return Inertia::render('alt-redirect::Index', [ 'blueprint' => $blueprint->toPublishArray(), - 'values' => $fields->values(), - 'meta' => $fields->meta(), - 'data' => $values, + 'initialValues' => $fields->values()->all(), + 'initialMeta' => $fields->meta()->all(), + 'items' => $values, 'type' => $this->type, 'action' => $this->actions[$this->type], 'title' => $this->titles[$this->type], @@ -72,7 +72,7 @@ public function index() public function create(Request $request) { - $data = new Data($this->type); + $repository = app(RepositoryInterface::class); // Get a blueprint. $blueprint = with(new BlueprintRepository)->setDirectory(__DIR__.'/../../../resources/blueprints')->find($this->type); @@ -82,7 +82,9 @@ public function create(Request $request) // Add the values to the array $arr = $request->all(); - $arr['id'] = uniqid(); + if (empty($arr['id'])) { + $arr['id'] = uniqid(); + } // Avoid looping redirects (caught by validation, but give a more helpful error) if (($this->type == 'redirects') && ($arr['to'] === $arr['from'])) { @@ -100,52 +102,36 @@ public function create(Request $request) $fields = $fields->addValues($arr); $fields->validate(); - $data->setAll($fields->process()->values()->toArray()); - - $data = new Data($this->type); - $values = $data->all(); + $repository->save($this->type, $fields->process()->values()->toArray()); - return [ - 'data' => $values, - ]; + return redirect()->back()->with([ + 'items' => $repository->all($this->type), + ]); } public function delete(Request $request) { - $disk = (new Manager())->disk(); - switch($this->type) { - case "redirects" : - $disk->delete('content/alt-redirect/' . hash('sha512', base64_encode($request->from)) . '.yaml'); - $disk->delete('content/alt-redirect/' . base64_encode($request->from) . '.yaml'); - $disk->delete('content/alt-redirect/alt-regex/' . hash('sha512', base64_encode($request->id)) . '.yaml'); - $disk->delete('content/alt-redirect/alt-regex/'. base64_encode($request->id) . '.yaml'); - break; - case 'query-strings': - $disk->delete('content/alt-redirect/query-strings/' . hash('sha512', base64_encode($request->query_string)) . '.yaml'); - break; - } + $repository = app(RepositoryInterface::class); + $repository->delete($this->type, $request->all()); - $data = new Data($this->type); - $values = $data->all(); - - return [ - 'data' => $values, - ]; + return redirect()->back()->with([ + 'items' => $repository->all($this->type), + ]); } // Import and Export can stay hardcoded to redirects since I/O for Query Strings aren't supported atm public function export(Request $request) { - $data = new Data('redirects'); + $redirects = app(RepositoryInterface::class)->all('redirects'); - $callback = function () use ($data) { + $callback = function () use ($redirects) { $df = fopen('php://output', 'w'); fputcsv($df, ['from', 'to', 'redirect_type', 'sites', 'id']); // Use the data from the request instead of fetching from the database - foreach ($data->data as $row) { - fputcsv($df, [$row['from'], $row['to'], $row['redirect_type'], is_array($row['sites']) ? implode(',', $row['sites']) : $row['sites'], $row['id']]); // Adjust as per your data structure + foreach ($redirects as $row) { + fputcsv($df, [$row['from'], $row['to'], $row['redirect_type'], is_array($row['sites'] ?? null) ? implode(',', $row['sites']) : ($row['sites'] ?? ''), $row['id']]); // Adjust as per your data structure } fclose($df); @@ -159,7 +145,9 @@ public function export(Request $request) public function import(Request $request) { - $currentData = json_decode($request->get('data'), true); + $repository = app(RepositoryInterface::class); + $currentData = $repository->all('redirects'); + $file = $request->file('file'); $handle = fopen($file->path(), 'r'); if ($handle !== false) { @@ -169,7 +157,7 @@ public function import(Request $request) 'from' => $row[0], 'to' => $row[1], 'redirect_type' => $row[2], - 'sites' => !empty($row[3] ?? false) ? explode(',', $row[3]) : ['default'], + 'sites' => ! empty($row[3] ?? false) ? explode(',', $row[3]) : ['default'], 'id' => ! empty($row[4] ?? false) ? $row[4] : uniqid(), ]; // Skip the redirect if it'll create an infinite loop (handles empty redirects too) @@ -189,39 +177,39 @@ public function import(Request $request) // Close the file handle fclose($handle); } - $data = new Data('redirects'); - $data->saveAll($currentData); + $repository->saveAll('redirects', $currentData); + return redirect()->back()->with([ + 'items' => $repository->all('redirects'), + ]); } // Toggle a key in a certain item and return the data afterwards public function toggle(Request $request) { - $toggleKey = $request->get('toggleKey'); - $index = $request->get('index'); - $data = new Data($this->type); + $toggleKey = $request->get('toggleKey'); + $index = $request->get('index'); + $repository = app(RepositoryInterface::class); switch ($this->type) { case 'query-strings': - $item = $data->getByKey('query_string', $index); + $item = $repository->find($this->type, 'query_string', $index); if ($item === null) { return response('Error finding item', 500); } - if (!isset($item[$toggleKey])) { + if (! isset($item[$toggleKey])) { $item[$toggleKey] = false; } - $item[$toggleKey] = !$item[$toggleKey]; - $data->setAll($item); + $item[$toggleKey] = ! $item[$toggleKey]; + $repository->save($this->type, $item); break; default: return response('Method not implemented', 500); } - $data = new Data($this->type); - $values = $data->all(); - return [ - 'data' => $values, - ]; + return redirect()->back()->with([ + 'items' => $repository->all($this->type), + ]); } } diff --git a/src/Http/Middleware/CheckForRedirects.php b/src/Http/Middleware/CheckForRedirects.php index 54fe511..57c1f75 100644 --- a/src/Http/Middleware/CheckForRedirects.php +++ b/src/Http/Middleware/CheckForRedirects.php @@ -1,90 +1,122 @@ -redirectWithPreservedParams($to , $redirect['redirect_type'] ?? 301); - } + $path = URISupport::uriWithFilteredQueryStrings($pathOnly); + $permuPath = URISupport::uriWithFilteredQueryStrings($permuPathOnly); + + // Check simple redirects + $redirect = $repository->find('redirects', 'from', $path) ?? + $repository->find('redirects', 'from', $permuPath) ?? + $repository->find('redirects', 'from', $pathOnly) ?? + $repository->find('redirects', 'from', $permuPathOnly); + + if ($redirect) { + $to = $redirect['to'] ?? '/'; + // There's no need to redirect. + if ($to === $path || $to === $permuPath || $to === $pathOnly || $to === $permuPathOnly) { + return $next($request); + } + if (! ($redirect['sites'] ?? false) || (in_array(Site::current(), $redirect['sites']))) { + return $this->redirectWithPreservedParams($to, $redirect['redirect_type'] ?? 301); } } - //Regex checks - $data = new Data('redirect', true); - foreach ($data->regexData as $redirect) { - if (preg_match('#' . $redirect['from'] . '#', $uri)) { - $redirectTo = preg_replace('#' . $redirect['from'] . '#', $redirect['to'], $uri); - if (!($redirect['sites'] ?? false) || (in_array(Site::current(), $redirect['sites']))) { + // Regex checks + $uri = URISupport::uriWithFilteredQueryStrings(); + foreach ($repository->getRegex('redirects') as $redirect) { + $from = $redirect['from']; + + // Determine if the pattern is already delimited + $isDelimited = @preg_match($from, '') !== false; + $pattern = $isDelimited ? $from : '#' . $from . '#'; + + // Handle the ? hack for non-delimited patterns + if (!$isDelimited && ! preg_match($pattern, $uri) && strpos($from, '?') !== false && strpos($from, '\?') === false) { + $pattern = '#' . str_replace('?', '\?', $from) . '#'; + } + + if (preg_match($pattern, $uri)) { + $redirectTo = preg_replace($pattern, $redirect['to'], $uri); + if (! ($redirect['sites'] ?? false) || (in_array(Site::current(), $redirect['sites']))) { return $this->redirectWithPreservedParams($redirectTo ?? '/', $redirect['redirect_type'] ?? 301); } } } - //No redirect + + // No redirect return $next($request); } private function redirectWithPreservedParams($to, $status) { - $preserveKeys = []; - foreach ((new Data('query-strings'))->all() as $item) { - if (!($item['strip'] ?? false)) { - $preserveKeys[] = $item['query_string']; + $stripKeys = []; + $repository = app(RepositoryInterface::class); + foreach ($repository->all('query-strings') as $item) { + if ($item['strip'] ?? false) { + $stripKeys[] = strtolower($item['query_string']); } } + // Parse raw query string to handle double-encoding and duplicates + $rawQueryString = request()->getQueryString(); $filteredStrings = []; - foreach(request()->all() as $key => $value) { - if (!in_array($key, $preserveKeys)) { - continue; + $seenKeys = []; + + if ($rawQueryString) { + // Decode the query string recursively to handle multiple levels of encoding + $decodedQueryString = $rawQueryString; + $previousQueryString = ''; + + // Keep decoding until no more changes occur (handles double/triple encoding) + while ($decodedQueryString !== $previousQueryString) { + $previousQueryString = $decodedQueryString; + $decodedQueryString = urldecode($decodedQueryString); + } + + parse_str($decodedQueryString, $parsedParams); + + foreach ($parsedParams as $key => $value) { + $normalizedKey = strtolower($key); + // Strip only parameters marked with strip:true, preserve all others + if (! in_array($normalizedKey, $stripKeys) && ! isset($seenKeys[$normalizedKey])) { + $seenKeys[$normalizedKey] = true; + $filteredStrings[] = sprintf('%s=%s', urlencode($key), urlencode($value)); + } } - $filteredStrings[] = sprintf( "%s=%s", $key, $value); } if ($filteredStrings) { $to .= str_contains($to, '?') ? '&' : '?'; $to .= implode('&', $filteredStrings); } - return redirect($to , $status, config('alt-redirect.headers', [])); + + return redirect($to, $status, config('alt-redirect.headers', [])); } } - diff --git a/src/Models/QueryString.php b/src/Models/QueryString.php new file mode 100644 index 0000000..558be4f --- /dev/null +++ b/src/Models/QueryString.php @@ -0,0 +1,26 @@ + 'boolean', + 'sites' => 'array', + ]; + + public $incrementing = false; + + protected $keyType = 'string'; +} diff --git a/src/Models/Redirect.php b/src/Models/Redirect.php new file mode 100644 index 0000000..bf32c84 --- /dev/null +++ b/src/Models/Redirect.php @@ -0,0 +1,28 @@ + 'array', + 'is_regex' => 'boolean', + ]; + + public $incrementing = false; + + protected $keyType = 'string'; +} diff --git a/src/Repositories/DatabaseRepository.php b/src/Repositories/DatabaseRepository.php new file mode 100644 index 0000000..1d16540 --- /dev/null +++ b/src/Repositories/DatabaseRepository.php @@ -0,0 +1,96 @@ +toArray(); + } elseif ($type === 'query-strings') { + return QueryString::all()->toArray(); + } + + return []; + } + + public function getRegex(string $type): array + { + if ($type !== 'redirects') { + return []; + } + + return Redirect::where('is_regex', true)->get()->toArray(); + } + + public function find(string $type, string $key, $value): ?array + { + if ($type === 'redirects') { + $model = Redirect::where($key, $value)->first(); + + return $model ? $model->toArray() : null; + } elseif ($type === 'query-strings') { + $model = QueryString::where($key, $value)->first(); + + return $model ? $model->toArray() : null; + } + + return null; + } + + public function save(string $type, array $data): void + { + if ($type === 'redirects') { + if (! isset($data['from'])) { + return; + } + $data['is_regex'] = URISupport::isRegex($data['from']); + if (empty($data['id'])) { + $existing = Redirect::where('from', $data['from'])->first(); + $data['id'] = $existing ? $existing->id : (string) uniqid(); + } + Redirect::updateOrCreate(['id' => $data['id']], $data); + } elseif ($type === 'query-strings') { + if (empty($data['id'])) { + $existing = QueryString::where('query_string', $data['query_string'])->first(); + $data['id'] = $existing ? $existing->id : (string) uniqid(); + } + QueryString::updateOrCreate(['id' => $data['id']], $data); + } + } + + protected function isRegex(string $str): bool + { + return URISupport::isRegex($str); + } + + public function saveAll(string $type, array $data): void + { + foreach ($data as $item) { + $this->save($type, $item); + } + } + + public function delete(string $type, array $data): void + { + if ($type === 'redirects') { + if (isset($data['id'])) { + Redirect::where('id', $data['id'])->delete(); + } elseif (isset($data['from'])) { + Redirect::where('from', $data['from'])->delete(); + } + } elseif ($type === 'query-strings') { + if (isset($data['id'])) { + QueryString::where('id', $data['id'])->delete(); + } elseif (isset($data['query_string'])) { + QueryString::where('query_string', $data['query_string'])->delete(); + } + } + } +} diff --git a/src/Repositories/FileRepository.php b/src/Repositories/FileRepository.php new file mode 100644 index 0000000..ba43a19 --- /dev/null +++ b/src/Repositories/FileRepository.php @@ -0,0 +1,171 @@ + [ + 'content/alt-redirect', + 'content/alt-redirect/alt-regex', + ], + 'query-strings' => [ + 'content/alt-redirect/query-strings', + ], + ]; + + public function __construct() + { + $this->manager = new Manager; + $this->checkOrMakeDirectories(); + } + + public function all(string $type): array + { + if (! isset($this->paths[$type])) { + return []; + } + + $allData = []; + $disk = $this->manager->disk(); + foreach ($this->paths[$type] as $path) { + if (! $disk->exists($path)) { + continue; + } + $allData = array_merge($allData, $disk->getFiles($path)->filter(fn ($f) => str_ends_with($f, '.yaml'))->all()); + } + + $allData = collect($allData)->sortByDesc(function ($file) use ($disk) { + return $disk->lastModified($file); + }); + + $results = []; + foreach ($allData as $file) { + $results[] = YAML::parse($disk->get($file)); + } + + return $results; + } + + public function getRegex(string $type): array + { + if ($type !== 'redirects' && $type !== 'redirect') { + return []; + } + + $disk = $this->manager->disk(); + if (! $disk->exists('content/alt-redirect/alt-regex')) { + return []; + } + + $allRegexRedirects = $disk->getFilesRecursively('content/alt-redirect/alt-regex')->all(); + $allRegexRedirects = collect($allRegexRedirects)->sortBy(function ($file) use ($disk) { + return $disk->lastModified($file); + }); + + $results = []; + foreach ($allRegexRedirects as $file) { + $results[] = YAML::parse($disk->get($file)); + } + + return $results; + } + + public function find(string $type, string $key, $value): ?array + { + if ($type === 'redirects' && $key === 'from') { + $b64 = base64_encode($value); + $possibleFiles = [ + 'content/alt-redirect/'.$b64.'.yaml', + 'content/alt-redirect/'.hash('sha512', $b64).'.yaml', + ]; + + foreach ($possibleFiles as $file) { + if ($this->manager->disk()->exists($file)) { + return YAML::parse($this->manager->disk()->get($file)); + } + } + } + + $data = collect($this->all($type)); + + return $data->firstWhere($key, $value); + } + + public function save(string $type, array $data): void + { + switch ($type) { + case 'redirects': + if (! isset($data['from'])) { + return; + } + if (! URISupport::isRegex($data['from'])) { + $this->manager->disk()->put('content/alt-redirect/'.hash('sha512', (base64_encode($data['from']))).'.yaml', YAML::dump($data)); + + return; + } + $this->manager->disk()->put('content/alt-redirect/alt-regex/'.hash('sha512', base64_encode($data['id'])).'.yaml', YAML::dump($data)); + break; + case 'query-strings': + $this->manager->disk()->put('content/alt-redirect/query-strings/'.hash('sha512', (base64_encode($data['query_string']))).'.yaml', YAML::dump($data)); + break; + } + } + + protected function isRegex(string $str): bool + { + return URISupport::isRegex($str); + } + + public function saveAll(string $type, array $data): void + { + foreach ($data as $item) { + $this->save($type, $item); + } + } + + public function delete(string $type, array $data): void + { + $disk = $this->manager->disk(); + switch ($type) { + case 'redirects': + if (isset($data['from'])) { + $disk->delete('content/alt-redirect/'.hash('sha512', base64_encode($data['from'])).'.yaml'); + $disk->delete('content/alt-redirect/'.base64_encode($data['from']).'.yaml'); + } + if (isset($data['id'])) { + $disk->delete('content/alt-redirect/alt-regex/'.hash('sha512', base64_encode($data['id'])).'.yaml'); + $disk->delete('content/alt-redirect/alt-regex/'.base64_encode($data['id']).'.yaml'); + } + break; + case 'query-strings': + if (isset($data['query_string'])) { + $disk->delete('content/alt-redirect/query-strings/'.hash('sha512', base64_encode($data['query_string'])).'.yaml'); + } elseif (isset($data['id'])) { + $item = $this->find($type, 'id', $data['id']); + if ($item && isset($item['query_string'])) { + $disk->delete('content/alt-redirect/query-strings/'.hash('sha512', base64_encode($item['query_string'])).'.yaml'); + } + } + break; + } + } + + protected function checkOrMakeDirectories(): void + { + foreach ($this->paths as $type) { + foreach ($type as $directory) { + if (! $this->manager->disk()->exists($directory)) { + $this->manager->disk()->makeDirectory($directory); + } + } + } + } +} diff --git a/src/Repositories/RepositoryManager.php b/src/Repositories/RepositoryManager.php new file mode 100644 index 0000000..5a7d3fe --- /dev/null +++ b/src/Repositories/RepositoryManager.php @@ -0,0 +1,24 @@ +config->get('alt-redirect.driver', 'file'); + } + + public function createFileDriver(): RepositoryInterface + { + return new FileRepository; + } + + public function createDatabaseDriver(): RepositoryInterface + { + return new DatabaseRepository; + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 6d4ca13..30edbf0 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -1,15 +1,20 @@ - [ 'resources/js/alt-redirect-addon.js', - 'resources/css/alt-redirect-addon.css' + 'resources/css/alt-redirect-addon.css', ], 'publicDirectory' => 'resources/dist', ]; protected $middlewareGroups = [ 'web' => [ - \AltDesign\AltRedirect\Http\Middleware\CheckForRedirects::class, - ] + CheckForRedirects::class, + ], ]; /** * Register our addon and child menus in the nav - * - * @return self */ - public function addToNav() : self + public function addToNav(): self { Nav::extend(function ($nav) { $nav->content('Alt Redirect') @@ -54,73 +57,71 @@ public function addToNav() : self /** * Register our permissions, so we can control who can see the settings. - * - * @return self */ - public function registerPermissions() : self + public function registerPermissions(): self { Permission::register('view alt-redirect') - ->label('View Alt Redirect Settings'); + ->label('View Alt Redirect Settings'); return $this; } /** * Register our artisan commands - * - * @return self */ - public function registerCommands() : self + public function registerCommands(): self { $this->commands([ DefaultQueryStringsCommand::class, + MigrateFileRedirectsCommand::class, + ReScanRegexCommand::class, ]); + return $this; } - /** - * Install the default query strings - * - * @return self - */ - public function installDefaultQueryStrings() : self + public function installDefaultQueryStrings(): self { + if (config('alt-redirect.driver') !== 'file') { + return $this; + } + // create the standard - if ($this->app->runningInConsole()) { - $disk = (new Manager())->disk(); - if (!$disk->exists('content/alt-redirect/.installed')) { - (new DefaultQueryStrings)->makeDefaultQueryStrings(); - } + $disk = (new Manager)->disk(); + if (! $disk->exists('content/alt-redirect/.installed')) { + (new DefaultQueryStrings)->makeDefaultQueryStrings(); + $disk->put('content/alt-redirect/.installed', ''); } + return $this; } - public function configureSSG() : self + public function configureSSG(): self { - if (!class_exists(SSG::class)) { + if (! class_exists(SSG::class)) { return $this; } SSG::after(function () { $dest = config('statamic.ssg.destination'); $base = rtrim(config('statamic.ssg.base_url'), '/'); // remove trailing slash - $disk = (new Manager())->disk(); + $disk = (new Manager)->disk(); - $redirects = (new Data('redirects'))->all(); - print("Found " . count($redirects) . " redirects\n"); + $redirects = app(RepositoryInterface::class)->all('redirects'); + echo 'Found '.count($redirects)." redirects\n"; $generated = $directories = 0; - foreach( $redirects as $redirect ) { - $fromDir = $dest . $redirect['from']; + foreach ($redirects as $redirect) { + $fromDir = $dest.$redirect['from']; $from = sprintf('%s%sindex.html', $fromDir, (Str::endsWith($fromDir, '/') ? '' : '/') ); - $to = $base . $redirect['to']; + $to = $base.$redirect['to']; - if (!$disk->exists($from)) { + if (! $disk->exists($from)) { $contents = view('alt-redirect::ssg', ['to' => $to]); - if (!$disk->isDirectory($fromDir)) { + if (! $disk->isDirectory($fromDir)) { mkdir($fromDir, 0777, true); $directories++; } @@ -130,17 +131,45 @@ public function configureSSG() : self } } - print("Generated $generated redirect files in $directories new directories\n"); + echo "Generated $generated redirect files in $directories new directories\n"; }); + return $this; } + public function register() + { + parent::register(); + + $this->mergeConfigFrom(__DIR__.'/../config/alt-redirect.php', 'alt-redirect'); + + $this->app->singleton(RepositoryManager::class, function ($app) { + return new RepositoryManager($app); + }); + + $this->app->bind(RepositoryInterface::class, function ($app) { + return $app->make(RepositoryManager::class)->driver(); + }); + } + public function bootAddon() { + if ($this->app->runningInConsole()) { + $this->loadMigrationsFrom(__DIR__.'/Database/Migrations'); + + $this->publishes([ + __DIR__.'/../config/alt-redirect.php' => config_path('alt-redirect.php'), + ], 'alt-redirect-config'); + + $this->publishes([ + __DIR__.'/Database/Migrations' => database_path('migrations'), + ], 'alt-redirect-migrations'); + } + $this->addToNav() ->registerPermissions() ->registerCommands() - ->configureSSG(); + ->configureSSG() + ->installDefaultQueryStrings(); } } - diff --git a/tests/Feature/ControllerDuplicateTest.php b/tests/Feature/ControllerDuplicateTest.php new file mode 100644 index 0000000..70d63fe --- /dev/null +++ b/tests/Feature/ControllerDuplicateTest.php @@ -0,0 +1,69 @@ + 'database']); + // Authenticate as a user to bypass middleware if necessary + $user = User::make()->makeSuper()->save(); + $this->actingAs($user); +}); + +it('does not create duplicate redirects when updating', function () { + // 1. Create an initial redirect in the database + $redirect = Redirect::create([ + 'id' => 'existing-id', + 'from' => '/old-path', + 'to' => '/initial-new-path', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + // 2. Post to the create endpoint with the SAME ID but different 'to' path + $response = $this->post(cp_route('alt-redirect.create'), [ + 'id' => 'existing-id', + 'from' => '/old-path', + 'to' => '/updated-new-path', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $response->assertStatus(302); // Should redirect back + + // 3. Verify that we still only have ONE record and it was UPDATED + $redirects = Redirect::where('from', '/old-path')->get(); + + expect($redirects->count())->toBe(1); + expect($redirects->first()->id)->toBe('existing-id'); + expect($redirects->first()->to)->toBe('/updated-new-path'); +}); + +it('does not create duplicate query strings when updating', function () { + // 1. Create an initial query string + $qs = QueryString::create([ + 'id' => 'existing-qs-id', + 'query_string' => 'gclid', + 'strip' => true, + 'sites' => ['default'], + ]); + + // 2. Post to the create endpoint with the SAME ID but different 'strip' value + $response = $this->post(cp_route('alt-redirect.query-strings.create'), [ + 'type' => 'query-strings', + 'id' => 'existing-qs-id', + 'query_string' => 'gclid', + 'strip' => false, + 'sites' => ['default'], + ]); + + $response->assertStatus(302); + + // 3. Verify that we still only have ONE record and it was UPDATED + $queryStrings = QueryString::where('query_string', 'gclid')->get(); + + expect($queryStrings->count())->toBe(1); + expect($queryStrings->first()->id)->toBe('existing-qs-id'); + expect($queryStrings->first()->strip)->toBe(false); +}); diff --git a/tests/Feature/DatabaseControlPanelTest.php b/tests/Feature/DatabaseControlPanelTest.php new file mode 100644 index 0000000..a74753d --- /dev/null +++ b/tests/Feature/DatabaseControlPanelTest.php @@ -0,0 +1,22 @@ + 'database']); + $this->user = User::make()->makeSuper()->save(); +}); + +it('can access the redirects index page', function () { + $this->actingAs($this->user) + ->get(cp_route('alt-redirect.index')) + ->assertStatus(200) + ->assertSee('Alt Redirect'); +}); + +it('can access the query strings index page', function () { + $this->actingAs($this->user) + ->get(cp_route('alt-redirect.query-strings.index')) + ->assertStatus(200) + ->assertSee('Alt Redirect - Query Strings'); +}); diff --git a/tests/Feature/DatabaseCsvTest.php b/tests/Feature/DatabaseCsvTest.php new file mode 100644 index 0000000..683b63a --- /dev/null +++ b/tests/Feature/DatabaseCsvTest.php @@ -0,0 +1,56 @@ + 'database']); + $this->user = User::make()->makeSuper()->save(); +}); + +it('can export redirects as csv', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'export-test-1', + 'from' => '/export-old-1', + 'to' => '/export-new-1', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $response = $this->actingAs($this->user) + ->get(cp_route('alt-redirect.export')); + + $response->assertStatus(200); + $response->assertHeader('Content-Type', 'text/csv; charset=UTF-8'); + + $content = $response->streamedContent(); + $rows = explode("\n", trim($content)); + + expect($rows)->toHaveCount(2); + expect($rows[0])->toBe('from,to,redirect_type,sites,id'); + expect($rows[1])->toContain('/export-old-1,/export-new-1,301,default,export-test-1'); +}); + +it('can import redirects from csv', function () { + $csvContent = "from,to,redirect_type,sites,id\n"; + $csvContent .= "/import-old-1,/import-new-1,301,default,import-test-1\n"; + $csvContent .= '/import-old-2,/import-new-2,302,"default,other",import-test-2'; + + $file = UploadedFile::fake()->createWithContent('redirects.csv', $csvContent); + + $response = $this->actingAs($this->user) + ->post(cp_route('alt-redirect.import'), [ + 'file' => $file, + ]); + + $response->assertRedirect(); + + $repository = app(RepositoryInterface::class); + $redirects = $repository->all('redirects'); + + expect($redirects)->toHaveCount(2); + expect($redirects[0]['from'])->toBe('/import-old-1'); + expect($redirects[1]['sites'])->toBe(['default', 'other']); +}); diff --git a/tests/Feature/DatabaseRedirectTest.php b/tests/Feature/DatabaseRedirectTest.php new file mode 100644 index 0000000..d4ba8f6 --- /dev/null +++ b/tests/Feature/DatabaseRedirectTest.php @@ -0,0 +1,154 @@ + 'database']); +}); + +it('can redirect a simple path', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'simple-test-database', + 'from' => '/old-path-database', + 'to' => '/new-path-database', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/old-path-database') + ->assertRedirect('/new-path-database') + ->assertStatus(301); +}); + +it('can redirect with regex', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'regex-test-database', + 'from' => '/old-database/(.*)', + 'to' => '/new-database/$1', + 'redirect_type' => 302, + 'sites' => ['default'], + ]); + + $this->get('/old-database/something') + ->assertRedirect('/new-database/something') + ->assertStatus(302); +}); + +it('can redirect with query strings in regex', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'query-regex-test-database', + 'from' => '/test-database\?source=(.*)', + 'to' => '/beans-database/$1', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + // It should match and redirect, but since 'source' is not stripped, it's added back to the target + $this->get('/test-database?source=mandem') + ->assertRedirect('/beans-database/mandem?source=mandem') + ->assertStatus(301); +}); + +it('can strip query strings', function () { + $repository = app(RepositoryInterface::class); + $repository->save('query-strings', [ + 'id' => 'strip-gclid-database', + 'query_string' => 'gclid', + 'strip' => true, + 'sites' => ['default'], + ]); + + $repository->save('redirects', [ + 'id' => 'simple-strip-test-database', + 'from' => '/old-strip-database', + 'to' => '/new-strip-database', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/old-strip-database?gclid=123&other=456') + ->assertRedirect('/new-strip-database?other=456') + ->assertStatus(301); +}); + +it('can handle site specific redirects', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'site-test-database', + 'from' => '/only-default-database', + 'to' => '/matched-database', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + Site::setCurrent('default'); + $this->get('/only-default-database')->assertRedirect('/matched-database'); + + Site::setCurrent('other'); + // If not default site, it should not match and return 404 + $this->get('/only-default-database')->assertNotFound(); +}); + +it('forgives unescaped question marks in regex', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'unescaped-test-database', + 'from' => '/test-unescaped-database?source=(.*)', // Unescaped ? + 'to' => '/new-test-database/$1', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/test-unescaped-database?source=mandem') + ->assertRedirect('/new-test-database/mandem?source=mandem'); +}); + +it('can redirect with non-standard regex', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'non-standard-regex-database', + 'from' => '^/products/(.+)$', + 'to' => '/shop/$1', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/products/shoes') + ->assertRedirect('/shop/shoes') + ->assertStatus(301); +}); + +it('can delete a redirect', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'delete-test-database', + 'from' => '/old-delete', + 'to' => '/new-delete', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $repository->delete('redirects', ['id' => 'delete-test-database', 'from' => '/old-delete']); + + $this->get('/old-delete')->assertNotFound(); +}); + +it('can delete a query string', function () { + $repository = app(RepositoryInterface::class); + $repository->save('query-strings', [ + 'id' => 'delete-qs-database', + 'query_string' => 'gclid-delete', + 'strip' => true, + 'sites' => ['default'], + ]); + + // This is what the frontend does: sends only query_string + $repository->delete('query-strings', ['query_string' => 'gclid-delete']); + + // Check it's gone + expect($repository->find('query-strings', 'query_string', 'gclid-delete'))->toBeNull(); +}); diff --git a/tests/Feature/DatabaseRepositoryTest.php b/tests/Feature/DatabaseRepositoryTest.php new file mode 100644 index 0000000..490d0ad --- /dev/null +++ b/tests/Feature/DatabaseRepositoryTest.php @@ -0,0 +1,111 @@ + '/old-url', + 'to' => '/new-url', + 'redirect_type' => '301', + 'sites' => ['default'], + ]; + + $repository->save('redirects', $data); + + $redirect = Redirect::where('from', '/old-url')->first(); + expect($redirect)->not->toBeNull(); + expect($redirect->id)->not->toBeEmpty(); + expect($redirect->to)->toBe('/new-url'); +}); + +it('saves query string without id by generating one', function () { + $repository = new DatabaseRepository(); + $data = [ + 'query_string' => 'foo=bar', + 'strip' => true, + 'sites' => ['default'], + ]; + + $repository->save('query-strings', $data); + + $qs = QueryString::where('query_string', 'foo=bar')->first(); + expect($qs)->not->toBeNull(); + expect($qs->id)->not->toBeEmpty(); +}); + +it('is idempotent when saving without id', function () { + $repository = new DatabaseRepository(); + $data = [ + 'from' => '/stable-url', + 'to' => '/target-1', + 'redirect_type' => '301', + 'sites' => ['default'], + ]; + + $repository->save('redirects', $data); + $redirect1 = Redirect::where('from', '/stable-url')->first(); + $id1 = $redirect1->id; + + // Save again with same from but different to + $data['to'] = '/target-2'; + $repository->save('redirects', $data); + $redirect2 = Redirect::where('from', '/stable-url')->first(); + + expect($redirect2->id)->toBe($id1); + expect($redirect2->to)->toBe('/target-2'); + expect(Redirect::where('from', '/stable-url')->count())->toBe(1); +}); + +it('is idempotent for query strings when saving without id', function () { + $repository = new DatabaseRepository(); + $data = [ + 'query_string' => 'stable-qs', + 'strip' => true, + 'sites' => ['default'], + ]; + + $repository->save('query-strings', $data); + $qs1 = QueryString::where('query_string', 'stable-qs')->first(); + $id1 = $qs1->id; + + // Save again with same query_string but different strip value + $data['strip'] = false; + $repository->save('query-strings', $data); + $qs2 = QueryString::where('query_string', 'stable-qs')->first(); + + expect($qs2->id)->toBe($id1); + expect($qs2->strip)->toBe(false); + expect(QueryString::where('query_string', 'stable-qs')->count())->toBe(1); +}); + +it('can migrate redirects without id from files to database', function () { + // 1. Setup file-based redirects without ID + $fileRepo = new FileRepository(); + $from = '/legacy-url'; + $fileRepo->save('redirects', [ + 'from' => $from, + 'to' => '/new-url', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + // 2. Verify it's in the FileRepository and has no ID + $legacy = $fileRepo->find('redirects', 'from', $from); + expect($legacy)->not->toBeNull(); + expect($legacy)->not->toHaveKey('id'); + + // 3. Run migration command + $this->artisan('alt-redirect:migrate-file-redirects') + ->expectsConfirmation('This will migrate all file-based redirects and query strings to the database. Do you wish to continue?', 'yes') + ->assertExitCode(0); + + // 4. Verify it's in the database with a generated ID + $redirect = Redirect::where('from', $from)->first(); + expect($redirect)->not->toBeNull(); + expect($redirect->id)->not->toBeEmpty(); + expect($redirect->to)->toBe('/new-url'); +}); diff --git a/tests/Feature/DefaultQueryStringInstallationTest.php b/tests/Feature/DefaultQueryStringInstallationTest.php new file mode 100644 index 0000000..7a3457c --- /dev/null +++ b/tests/Feature/DefaultQueryStringInstallationTest.php @@ -0,0 +1,36 @@ + 'database']); + + $disk = (new Manager)->disk(); + $markerPath = 'content/alt-redirect/.installed'; + + // 1. Ensure marker is gone + if ($disk->exists($markerPath)) { + $disk->delete($markerPath); + } + + // 2. Call the installation logic (normally called during boot) + app(\AltDesign\AltRedirect\ServiceProvider::class, ['app' => app()])->installDefaultQueryStrings(); + + // 3. Verify marker DOES NOT EXIST + expect($disk->exists($markerPath))->toBeFalse(); +}); + +it('seeds the database via migration', function () { + config(['alt-redirect.driver' => 'database']); + + // We need to re-run the migration with the database driver active + // Since we are in a test, the migrations might have run with 'file' driver initially + (new \AltDesign\AltRedirect\Helpers\DefaultQueryStrings)->makeDefaultQueryStrings(); + + $repository = app(\AltDesign\AltRedirect\Repositories\RepositoryManager::class)->driver('database'); + $queryStrings = $repository->all('query-strings'); + + expect($queryStrings)->not->toBeEmpty(); + expect($queryStrings)->toHaveCount(9); +}); diff --git a/tests/Feature/FileControlPanelTest.php b/tests/Feature/FileControlPanelTest.php new file mode 100644 index 0000000..c7c85ff --- /dev/null +++ b/tests/Feature/FileControlPanelTest.php @@ -0,0 +1,22 @@ + 'file']); + $this->user = User::make()->makeSuper()->save(); +}); + +it('can access the redirects index page', function () { + $this->actingAs($this->user) + ->get(cp_route('alt-redirect.index')) + ->assertStatus(200) + ->assertSee('Alt Redirect'); +}); + +it('can access the query strings index page', function () { + $this->actingAs($this->user) + ->get(cp_route('alt-redirect.query-strings.index')) + ->assertStatus(200) + ->assertSee('Alt Redirect - Query Strings'); +}); diff --git a/tests/Feature/FileCsvTest.php b/tests/Feature/FileCsvTest.php new file mode 100644 index 0000000..452af8f --- /dev/null +++ b/tests/Feature/FileCsvTest.php @@ -0,0 +1,56 @@ + 'file']); + $this->user = User::make()->makeSuper()->save(); +}); + +it('can export redirects as csv', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'export-test-1', + 'from' => '/export-old-1', + 'to' => '/export-new-1', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $response = $this->actingAs($this->user) + ->get(cp_route('alt-redirect.export')); + + $response->assertStatus(200); + $response->assertHeader('Content-Type', 'text/csv; charset=UTF-8'); + + $content = $response->streamedContent(); + $rows = explode("\n", trim($content)); + + expect($rows)->toHaveCount(2); + expect($rows[0])->toBe('from,to,redirect_type,sites,id'); + expect($rows[1])->toContain('/export-old-1,/export-new-1,301,default,export-test-1'); +}); + +it('can import redirects from csv', function () { + $csvContent = "from,to,redirect_type,sites,id\n"; + $csvContent .= "/import-old-1,/import-new-1,301,default,import-test-1\n"; + $csvContent .= '/import-old-2,/import-new-2,302,"default,other",import-test-2'; + + $file = UploadedFile::fake()->createWithContent('redirects.csv', $csvContent); + + $response = $this->actingAs($this->user) + ->post(cp_route('alt-redirect.import'), [ + 'file' => $file, + ]); + + $response->assertRedirect(); + + $repository = app(RepositoryInterface::class); + $redirects = $repository->all('redirects'); + + expect($redirects)->toHaveCount(2); + expect($redirects[0]['from'])->toBe('/import-old-1'); + expect($redirects[1]['sites'])->toBe(['default', 'other']); +}); diff --git a/tests/Feature/FileRedirectTest.php b/tests/Feature/FileRedirectTest.php new file mode 100644 index 0000000..7e5d6d5 --- /dev/null +++ b/tests/Feature/FileRedirectTest.php @@ -0,0 +1,154 @@ + 'file']); +}); + +it('can redirect a simple path', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'simple-test-file', + 'from' => '/old-path-file', + 'to' => '/new-path-file', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/old-path-file') + ->assertRedirect('/new-path-file') + ->assertStatus(301); +}); + +it('can redirect with regex', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'regex-test-file', + 'from' => '/old-file/(.*)', + 'to' => '/new-file/$1', + 'redirect_type' => 302, + 'sites' => ['default'], + ]); + + $this->get('/old-file/something') + ->assertRedirect('/new-file/something') + ->assertStatus(302); +}); + +it('can redirect with query strings in regex', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'query-regex-test-file', + 'from' => '/test-file\?source=(.*)', + 'to' => '/beans-file/$1', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + // It should match and redirect, but since 'source' is not stripped, it's added back to the target + $this->get('/test-file?source=mandem') + ->assertRedirect('/beans-file/mandem?source=mandem') + ->assertStatus(301); +}); + +it('can strip query strings', function () { + $repository = app(RepositoryInterface::class); + $repository->save('query-strings', [ + 'id' => 'strip-gclid-file', + 'query_string' => 'gclid', + 'strip' => true, + 'sites' => ['default'], + ]); + + $repository->save('redirects', [ + 'id' => 'simple-strip-test-file', + 'from' => '/old-strip-file', + 'to' => '/new-strip-file', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/old-strip-file?gclid=123&other=456') + ->assertRedirect('/new-strip-file?other=456') + ->assertStatus(301); +}); + +it('can handle site specific redirects', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'site-test-file', + 'from' => '/only-default-file', + 'to' => '/matched-file', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + Site::setCurrent('default'); + $this->get('/only-default-file')->assertRedirect('/matched-file'); + + Site::setCurrent('other'); + // If not default site, it should not match and return 404 + $this->get('/only-default-file')->assertNotFound(); +}); + +it('forgives unescaped question marks in regex', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'unescaped-test-file', + 'from' => '/test-unescaped-file?source=(.*)', // Unescaped ? + 'to' => '/new-test-file/$1', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/test-unescaped-file?source=mandem') + ->assertRedirect('/new-test-file/mandem?source=mandem'); +}); + +it('can redirect with non-standard regex', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'non-standard-regex-file', + 'from' => '^/products/(.+)$', + 'to' => '/shop/$1', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/products/shoes') + ->assertRedirect('/shop/shoes') + ->assertStatus(301); +}); + +it('can delete a redirect', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'delete-test-file', + 'from' => '/old-delete', + 'to' => '/new-delete', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $repository->delete('redirects', ['id' => 'delete-test-file', 'from' => '/old-delete']); + + $this->get('/old-delete')->assertNotFound(); +}); + +it('can delete a query string', function () { + $repository = app(RepositoryInterface::class); + $repository->save('query-strings', [ + 'id' => 'delete-qs-file', + 'query_string' => 'gclid-delete', + 'strip' => true, + 'sites' => ['default'], + ]); + + // This is what the frontend does: sends only query_string + $repository->delete('query-strings', ['query_string' => 'gclid-delete']); + + // Check it's gone + expect($repository->find('query-strings', 'query_string', 'gclid-delete'))->toBeNull(); +}); diff --git a/tests/Feature/InstallationTest.php b/tests/Feature/InstallationTest.php new file mode 100644 index 0000000..8654b29 --- /dev/null +++ b/tests/Feature/InstallationTest.php @@ -0,0 +1,17 @@ +all('query-strings'); + + // It should now install them automatically + expect($queryStrings)->not->toBeEmpty(); + expect($queryStrings)->toHaveCount(9); + + $handles = collect($queryStrings)->pluck('query_string')->toArray(); + expect($handles)->toContain('utm_source'); + expect($handles)->toContain('utm_medium'); + expect($handles)->toContain('gclid'); +}); diff --git a/tests/Feature/RegexDetectionTest.php b/tests/Feature/RegexDetectionTest.php new file mode 100644 index 0000000..9e92c13 --- /dev/null +++ b/tests/Feature/RegexDetectionTest.php @@ -0,0 +1,147 @@ +artisan('migrate'); +}); + +it('does not flag simple URLs with query strings as regex', function ($url) { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'test-id-'.md5($url), + 'from' => $url, + 'to' => '/target', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $redirect = $repository->find('redirects', 'from', $url); + + expect($redirect['is_regex'] ?? false)->toBeFalse(); +})->with([ + '/fire-equipment-and-first-aid-storage-cabinets/dangerous-and-flammable-substance-coshh-storage-cabinet/?combination=286_1295', + '/fire-equipment-and-first-aid-storage-cabinets/dangerous-and-flammable-substance-coshh-storage-cabinet/?combination=286_1297', + '/sti-theft-stoppers/emergency-lighting-covers/?sort_by=price&sort_order=asc', + '/industrial-spill-control/chemical-spill-control/?items_per_page=32', + '/disabled-equipment/c-tec-quantec-addressable-nurse-call/page-3/?sort_by=position&sort_order=asc', + '/search?q=foo', + '/test.html?a=b', + '/search/results+for+query', + '/product-categories/spill-care?selectedTaxonomies[product_categories][spill-care]=spill-care', + '/products/test.php', + '/search?q=(foo)', + '/credit-application/', + '/profiles-add/', + '/about/', + '/delivery/', + '/engineers-stock-and-spares/', + '/special-offers/', + '/services/', + '/fire-risk-assessment-en/', + '/online-training-courses/', + '/personal-safety/', + '/industrial-spill-control/', + '/disabled-equipment/', + '/fire-safety-stick/', + '/sti-theft-stoppers/', + '/fire-brigade-equipment/', + '/fire-hose-reels/', + '/intumescent-products/', + '/fire-door-furniture/', + '/fire-safety-signs/', + '/site-fire-alarms/', + '/emergency-lighting/', + '/fire-alarms/', + '/fire-equipment-and-storage-cabinets/', + '/stands-and-trolleys/', + '/fire-safety-packs/', + '/spares/', + '/shop/........../', + '/welding-drapes/', + '/fire-blankets/', + '/fire-extinguishers/', +]); + +it('still flags actual regex as regex', function ($url) { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'regex-id-'.md5($url), + 'from' => $url, + 'to' => '/target/$1', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $redirect = $repository->find('redirects', 'from', $url); + + expect($redirect['is_regex'] ?? false)->toBeTrue(); +})->with([ + '/products/(.*)', + '^/old-path/(.+)$', + '/blog/[0-9]{4}/[0-9]{2}/.*', + '/test/(foo|bar)', + '/test/\d+', + '#/products/(.*)#', + '/^foo$/i', +]); + +it('handles file driver regex detection correctly', function () { + Config::set('alt-redirect.driver', 'file'); + $repository = app(RepositoryInterface::class); + + // For file repository, we can't check 'is_regex' column but we can check if it's placed in the regex folder + // but the file repository implementation also uses isRegex to decide on filename + // Actually, FileRepository doesn't store is_regex in the YAML, but uses it for saving. + + $url = '/test?a=b'; + $repository->save('redirects', [ + 'id' => 'file-test', + 'from' => $url, + 'to' => '/target', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + // If it's NOT regex, it should be saved with a hashed filename + $expectedPath = 'content/alt-redirect/'.hash('sha512', base64_encode($url)).'.yaml'; + expect(File::disk()->exists($expectedPath))->toBeTrue(); +}); + +it('can re-scan redirects and fix regex flags', function () { + Config::set('alt-redirect.driver', 'database'); + $repository = app(RepositoryInterface::class); + + // Use model directly to bypass repository auto-calculation for setup + Redirect::create([ + 'id' => 'wrong-flag-1', + 'from' => '/simple-path?a=b', + 'to' => '/target', + 'redirect_type' => 301, + 'sites' => ['default'], + 'is_regex' => true, // Wrong! + ]); + + Redirect::create([ + 'id' => 'wrong-flag-2', + 'from' => '/products/(.*)', + 'to' => '/shop/$1', + 'redirect_type' => 301, + 'sites' => ['default'], + 'is_regex' => false, // Wrong! + ]); + + $this->artisan('alt-redirect:re-scan-regex') + ->expectsOutput('Successfully updated 2 redirects.') + ->assertExitCode(0); + + $redirect1 = $repository->find('redirects', 'id', 'wrong-flag-1'); + $redirect2 = $repository->find('redirects', 'id', 'wrong-flag-2'); + + expect($redirect1['is_regex'] ?? true)->toBeFalse(); + expect($redirect2['is_regex'] ?? false)->toBeTrue(); +}); diff --git a/tests/Feature/SimpleRedirectWithQueryStringTest.php b/tests/Feature/SimpleRedirectWithQueryStringTest.php new file mode 100644 index 0000000..00667ac --- /dev/null +++ b/tests/Feature/SimpleRedirectWithQueryStringTest.php @@ -0,0 +1,89 @@ + 'database']); +}); + +it('can redirect a simple path with a query string', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'simple-qs-test', + 'from' => '/old-qs?foo=bar', + 'to' => '/new-qs', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/old-qs?foo=bar') + ->assertRedirect('/new-qs?foo=bar') + ->assertStatus(301); +}); + +it('can redirect a simple path with a query string when other query strings are present and stripped', function () { + $repository = app(RepositoryInterface::class); + $repository->save('query-strings', [ + 'id' => 'strip-gclid', + 'query_string' => 'gclid', + 'strip' => true, + 'sites' => ['default'], + ]); + + $repository->save('redirects', [ + 'id' => 'simple-qs-strip-test', + 'from' => '/old-qs-strip?foo=bar', + 'to' => '/new-qs-strip', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/old-qs-strip?foo=bar&gclid=123') + ->assertRedirect('/new-qs-strip?foo=bar') + ->assertStatus(301); +}); + +it('can redirect a simple path without a query string when the request has a query string', function () { + $repository = app(RepositoryInterface::class); + $repository->save('redirects', [ + 'id' => 'simple-no-qs-test', + 'from' => '/old-no-qs', + 'to' => '/new-no-qs', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/old-no-qs?foo=bar') + ->assertRedirect('/new-no-qs?foo=bar') + ->assertStatus(301); +}); + +it('can redirect a simple path with a query string even with trailing slash differences', function () { + $repository = app(RepositoryInterface::class); + // 1. Redirect has trailing slash, request doesn't + $repository->save('redirects', [ + 'id' => 'simple-qs-trailing-test', + 'from' => '/old-qs-trailing/?foo=bar', + 'to' => '/new-qs-trailing', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/old-qs-trailing?foo=bar') + ->assertRedirect('/new-qs-trailing?foo=bar') + ->assertStatus(301); + + // 2. Redirect doesn't have trailing slash, request does + $repository->save('redirects', [ + 'id' => 'simple-qs-no-trailing-test', + 'from' => '/old-qs-no-trailing?foo=bar', + 'to' => '/new-qs-no-trailing', + 'redirect_type' => 301, + 'sites' => ['default'], + ]); + + $this->get('/old-qs-no-trailing/?foo=bar') + ->assertRedirect('/new-qs-no-trailing?foo=bar') + ->assertStatus(301); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..ccd03f1 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,6 @@ +in(__DIR__); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..bd078cd --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,92 @@ +set('statamic.edits.file', true); + $app['config']->set('statamic.users.repository', 'file'); + $app['config']->set('statamic.system.multisite', true); + $app['config']->set('statamic.editions.pro', true); + + // Configure sites + $app['config']->set('statamic.sites', [ + 'default' => [ + 'name' => 'English', + 'locale' => 'en_US', + 'url' => '/', + ], + 'other' => [ + 'name' => 'French', + 'locale' => 'fr_FR', + 'url' => '/fr/', + ], + ]); + + // Use a temporary database for testing + $app['config']->set('database.default', 'testbench'); + $app['config']->set('database.connections.testbench', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + // Mock the disk for file driver + $app['config']->set('filesystems.disks.local.root', __DIR__.'/__fixtures__/storage'); + $app->bind('filesystems.paths.standard', fn () => __DIR__.'/__fixtures__/storage'); + } + + protected function defineDatabaseMigrations() + { + $this->loadMigrationsFrom(__DIR__.'/../src/Database/Migrations'); + } + + protected function setUp(): void + { + parent::setUp(); + + Site::setSites(config('statamic.sites')); + } + + protected function tearDown(): void + { + if (file_exists(__DIR__.'/__fixtures__/storage/content/alt-redirect')) { + $this->deleteDirectory(__DIR__.'/__fixtures__/storage/content/alt-redirect'); + } + + parent::tearDown(); + } + + private function deleteDirectory($dir) + { + if (! file_exists($dir)) { + return true; + } + + if (! is_dir($dir)) { + return unlink($dir); + } + + foreach (scandir($dir) as $item) { + if ($item == '.' || $item == '..') { + continue; + } + + if (! $this->deleteDirectory($dir.DIRECTORY_SEPARATOR.$item)) { + return false; + } + } + + return rmdir($dir); + } +} diff --git a/tests/Unit/.gitkeep b/tests/Unit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/content/assets/.gitkeep b/tests/__fixtures__/content/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/content/collections/.gitkeep b/tests/__fixtures__/content/collections/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/content/globals/.gitkeep b/tests/__fixtures__/content/globals/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/content/navigation/.gitkeep b/tests/__fixtures__/content/navigation/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/content/structures/collections/.gitkeep b/tests/__fixtures__/content/structures/collections/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/content/structures/navigation/.gitkeep b/tests/__fixtures__/content/structures/navigation/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/content/submissions/.gitkeep b/tests/__fixtures__/content/submissions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/content/taxonomies/.gitkeep b/tests/__fixtures__/content/taxonomies/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/dev-null/.gitkeep b/tests/__fixtures__/dev-null/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/storage/content/.gitkeep b/tests/__fixtures__/storage/content/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/users/.yaml b/tests/__fixtures__/users/.yaml new file mode 100644 index 0000000..6ba8e4f --- /dev/null +++ b/tests/__fixtures__/users/.yaml @@ -0,0 +1,2 @@ +super: true +id: 9e62c86f-6e50-4f9a-a1e8-435bc5c9076f diff --git a/vite.config.js b/vite.config.js index 687a5cc..62b2ae4 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,6 @@ import { defineConfig } from 'vite'; import laravel from 'laravel-vite-plugin'; -import vue from '@vitejs/plugin-vue2'; +import statamic from '@statamic/cms/vite-plugin'; export default defineConfig({ plugins: [ @@ -11,6 +11,6 @@ export default defineConfig({ ], publicDirectory: 'resources/dist', }), - vue(), + statamic(), ], });